mcp-proxy-adapter 4.1.1__py3-none-any.whl → 6.0.1__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.
Files changed (200) hide show
  1. mcp_proxy_adapter/__main__.py +32 -0
  2. mcp_proxy_adapter/api/app.py +290 -33
  3. mcp_proxy_adapter/api/handlers.py +32 -6
  4. mcp_proxy_adapter/api/middleware/__init__.py +38 -32
  5. mcp_proxy_adapter/api/middleware/command_permission_middleware.py +148 -0
  6. mcp_proxy_adapter/api/middleware/error_handling.py +9 -0
  7. mcp_proxy_adapter/api/middleware/factory.py +243 -0
  8. mcp_proxy_adapter/api/middleware/logging.py +32 -6
  9. mcp_proxy_adapter/api/middleware/protocol_middleware.py +201 -0
  10. mcp_proxy_adapter/api/middleware/transport_middleware.py +122 -0
  11. mcp_proxy_adapter/api/middleware/unified_security.py +197 -0
  12. mcp_proxy_adapter/api/middleware/user_info_middleware.py +158 -0
  13. mcp_proxy_adapter/commands/__init__.py +19 -4
  14. mcp_proxy_adapter/commands/auth_validation_command.py +408 -0
  15. mcp_proxy_adapter/commands/base.py +66 -32
  16. mcp_proxy_adapter/commands/builtin_commands.py +95 -0
  17. mcp_proxy_adapter/commands/catalog_manager.py +838 -0
  18. mcp_proxy_adapter/commands/cert_monitor_command.py +620 -0
  19. mcp_proxy_adapter/commands/certificate_management_command.py +608 -0
  20. mcp_proxy_adapter/commands/command_registry.py +711 -354
  21. mcp_proxy_adapter/commands/dependency_manager.py +245 -0
  22. mcp_proxy_adapter/commands/echo_command.py +81 -0
  23. mcp_proxy_adapter/commands/health_command.py +8 -1
  24. mcp_proxy_adapter/commands/help_command.py +21 -14
  25. mcp_proxy_adapter/commands/hooks.py +200 -167
  26. mcp_proxy_adapter/commands/key_management_command.py +506 -0
  27. mcp_proxy_adapter/commands/load_command.py +176 -0
  28. mcp_proxy_adapter/commands/plugins_command.py +235 -0
  29. mcp_proxy_adapter/commands/protocol_management_command.py +232 -0
  30. mcp_proxy_adapter/commands/proxy_registration_command.py +409 -0
  31. mcp_proxy_adapter/commands/reload_command.py +48 -50
  32. mcp_proxy_adapter/commands/result.py +1 -0
  33. mcp_proxy_adapter/commands/role_test_command.py +141 -0
  34. mcp_proxy_adapter/commands/roles_management_command.py +697 -0
  35. mcp_proxy_adapter/commands/security_command.py +488 -0
  36. mcp_proxy_adapter/commands/ssl_setup_command.py +366 -0
  37. mcp_proxy_adapter/commands/token_management_command.py +529 -0
  38. mcp_proxy_adapter/commands/transport_management_command.py +144 -0
  39. mcp_proxy_adapter/commands/unload_command.py +158 -0
  40. mcp_proxy_adapter/config.py +394 -14
  41. mcp_proxy_adapter/core/app_factory.py +410 -0
  42. mcp_proxy_adapter/core/app_runner.py +272 -0
  43. mcp_proxy_adapter/core/auth_validator.py +606 -0
  44. mcp_proxy_adapter/core/certificate_utils.py +1045 -0
  45. mcp_proxy_adapter/core/client.py +574 -0
  46. mcp_proxy_adapter/core/client_manager.py +284 -0
  47. mcp_proxy_adapter/core/client_security.py +384 -0
  48. mcp_proxy_adapter/core/config_converter.py +405 -0
  49. mcp_proxy_adapter/core/config_validator.py +218 -0
  50. mcp_proxy_adapter/core/logging.py +19 -3
  51. mcp_proxy_adapter/core/mtls_asgi.py +156 -0
  52. mcp_proxy_adapter/core/mtls_asgi_app.py +187 -0
  53. mcp_proxy_adapter/core/protocol_manager.py +385 -0
  54. mcp_proxy_adapter/core/proxy_client.py +602 -0
  55. mcp_proxy_adapter/core/proxy_registration.py +522 -0
  56. mcp_proxy_adapter/core/role_utils.py +426 -0
  57. mcp_proxy_adapter/core/security_adapter.py +370 -0
  58. mcp_proxy_adapter/core/security_factory.py +239 -0
  59. mcp_proxy_adapter/core/security_integration.py +286 -0
  60. mcp_proxy_adapter/core/server_adapter.py +282 -0
  61. mcp_proxy_adapter/core/server_engine.py +270 -0
  62. mcp_proxy_adapter/core/settings.py +1 -0
  63. mcp_proxy_adapter/core/ssl_utils.py +234 -0
  64. mcp_proxy_adapter/core/transport_manager.py +292 -0
  65. mcp_proxy_adapter/core/unified_config_adapter.py +579 -0
  66. mcp_proxy_adapter/custom_openapi.py +22 -11
  67. mcp_proxy_adapter/examples/__init__.py +13 -4
  68. mcp_proxy_adapter/examples/basic_framework/__init__.py +9 -0
  69. mcp_proxy_adapter/examples/basic_framework/commands/__init__.py +4 -0
  70. mcp_proxy_adapter/examples/basic_framework/hooks/__init__.py +4 -0
  71. mcp_proxy_adapter/examples/basic_framework/main.py +44 -0
  72. mcp_proxy_adapter/examples/commands/__init__.py +5 -0
  73. mcp_proxy_adapter/examples/create_certificates_simple.py +550 -0
  74. mcp_proxy_adapter/examples/debug_request_state.py +112 -0
  75. mcp_proxy_adapter/examples/debug_role_chain.py +158 -0
  76. mcp_proxy_adapter/examples/demo_client.py +275 -0
  77. mcp_proxy_adapter/examples/examples/basic_framework/__init__.py +9 -0
  78. mcp_proxy_adapter/examples/examples/basic_framework/commands/__init__.py +4 -0
  79. mcp_proxy_adapter/examples/examples/basic_framework/hooks/__init__.py +4 -0
  80. mcp_proxy_adapter/examples/examples/basic_framework/main.py +44 -0
  81. mcp_proxy_adapter/examples/examples/full_application/__init__.py +12 -0
  82. mcp_proxy_adapter/examples/examples/full_application/commands/__init__.py +7 -0
  83. mcp_proxy_adapter/examples/examples/full_application/commands/custom_echo_command.py +80 -0
  84. mcp_proxy_adapter/examples/examples/full_application/commands/dynamic_calculator_command.py +90 -0
  85. mcp_proxy_adapter/examples/examples/full_application/hooks/__init__.py +7 -0
  86. mcp_proxy_adapter/examples/examples/full_application/hooks/application_hooks.py +75 -0
  87. mcp_proxy_adapter/examples/examples/full_application/hooks/builtin_command_hooks.py +71 -0
  88. mcp_proxy_adapter/examples/examples/full_application/main.py +173 -0
  89. mcp_proxy_adapter/examples/examples/full_application/proxy_endpoints.py +154 -0
  90. mcp_proxy_adapter/examples/full_application/__init__.py +12 -0
  91. mcp_proxy_adapter/examples/full_application/commands/__init__.py +7 -0
  92. mcp_proxy_adapter/examples/full_application/commands/custom_echo_command.py +80 -0
  93. mcp_proxy_adapter/examples/full_application/commands/dynamic_calculator_command.py +90 -0
  94. mcp_proxy_adapter/examples/full_application/hooks/__init__.py +7 -0
  95. mcp_proxy_adapter/examples/full_application/hooks/application_hooks.py +75 -0
  96. mcp_proxy_adapter/examples/full_application/hooks/builtin_command_hooks.py +71 -0
  97. mcp_proxy_adapter/examples/full_application/main.py +173 -0
  98. mcp_proxy_adapter/examples/full_application/proxy_endpoints.py +154 -0
  99. mcp_proxy_adapter/examples/generate_all_certificates.py +362 -0
  100. mcp_proxy_adapter/examples/generate_certificates.py +177 -0
  101. mcp_proxy_adapter/examples/generate_certificates_and_tokens.py +369 -0
  102. mcp_proxy_adapter/examples/generate_test_configs.py +331 -0
  103. mcp_proxy_adapter/examples/proxy_registration_example.py +334 -0
  104. mcp_proxy_adapter/examples/run_example.py +59 -0
  105. mcp_proxy_adapter/examples/run_full_test_suite.py +318 -0
  106. mcp_proxy_adapter/examples/run_proxy_server.py +146 -0
  107. mcp_proxy_adapter/examples/run_security_tests.py +544 -0
  108. mcp_proxy_adapter/examples/run_security_tests_fixed.py +247 -0
  109. mcp_proxy_adapter/examples/scripts/config_generator.py +740 -0
  110. mcp_proxy_adapter/examples/scripts/create_certificates_simple.py +560 -0
  111. mcp_proxy_adapter/examples/scripts/generate_certificates_and_tokens.py +369 -0
  112. mcp_proxy_adapter/examples/security_test_client.py +782 -0
  113. mcp_proxy_adapter/examples/setup_test_environment.py +328 -0
  114. mcp_proxy_adapter/examples/test_config.py +148 -0
  115. mcp_proxy_adapter/examples/test_config_generator.py +86 -0
  116. mcp_proxy_adapter/examples/test_examples.py +281 -0
  117. mcp_proxy_adapter/examples/universal_client.py +620 -0
  118. mcp_proxy_adapter/main.py +93 -0
  119. mcp_proxy_adapter/utils/config_generator.py +1008 -0
  120. mcp_proxy_adapter/version.py +5 -2
  121. mcp_proxy_adapter-6.0.1.dist-info/METADATA +679 -0
  122. mcp_proxy_adapter-6.0.1.dist-info/RECORD +140 -0
  123. mcp_proxy_adapter-6.0.1.dist-info/entry_points.txt +2 -0
  124. {mcp_proxy_adapter-4.1.1.dist-info → mcp_proxy_adapter-6.0.1.dist-info}/licenses/LICENSE +2 -2
  125. mcp_proxy_adapter/api/middleware/auth.py +0 -146
  126. mcp_proxy_adapter/api/middleware/rate_limit.py +0 -152
  127. mcp_proxy_adapter/commands/reload_settings_command.py +0 -125
  128. mcp_proxy_adapter/examples/README.md +0 -124
  129. mcp_proxy_adapter/examples/basic_server/README.md +0 -60
  130. mcp_proxy_adapter/examples/basic_server/__init__.py +0 -7
  131. mcp_proxy_adapter/examples/basic_server/basic_custom_settings.json +0 -39
  132. mcp_proxy_adapter/examples/basic_server/config.json +0 -35
  133. mcp_proxy_adapter/examples/basic_server/custom_settings_example.py +0 -238
  134. mcp_proxy_adapter/examples/basic_server/server.py +0 -103
  135. mcp_proxy_adapter/examples/custom_commands/README.md +0 -127
  136. mcp_proxy_adapter/examples/custom_commands/__init__.py +0 -27
  137. mcp_proxy_adapter/examples/custom_commands/advanced_hooks.py +0 -250
  138. mcp_proxy_adapter/examples/custom_commands/auto_commands/__init__.py +0 -6
  139. mcp_proxy_adapter/examples/custom_commands/auto_commands/auto_echo_command.py +0 -103
  140. mcp_proxy_adapter/examples/custom_commands/auto_commands/auto_info_command.py +0 -111
  141. mcp_proxy_adapter/examples/custom_commands/config.json +0 -35
  142. mcp_proxy_adapter/examples/custom_commands/custom_health_command.py +0 -169
  143. mcp_proxy_adapter/examples/custom_commands/custom_help_command.py +0 -215
  144. mcp_proxy_adapter/examples/custom_commands/custom_openapi_generator.py +0 -76
  145. mcp_proxy_adapter/examples/custom_commands/custom_settings.json +0 -96
  146. mcp_proxy_adapter/examples/custom_commands/custom_settings_manager.py +0 -241
  147. mcp_proxy_adapter/examples/custom_commands/data_transform_command.py +0 -135
  148. mcp_proxy_adapter/examples/custom_commands/echo_command.py +0 -122
  149. mcp_proxy_adapter/examples/custom_commands/hooks.py +0 -230
  150. mcp_proxy_adapter/examples/custom_commands/intercept_command.py +0 -123
  151. mcp_proxy_adapter/examples/custom_commands/manual_echo_command.py +0 -103
  152. mcp_proxy_adapter/examples/custom_commands/server.py +0 -228
  153. mcp_proxy_adapter/examples/custom_commands/test_hooks.py +0 -176
  154. mcp_proxy_adapter/examples/deployment/README.md +0 -49
  155. mcp_proxy_adapter/examples/deployment/__init__.py +0 -7
  156. mcp_proxy_adapter/examples/deployment/config.development.json +0 -8
  157. mcp_proxy_adapter/examples/deployment/config.json +0 -29
  158. mcp_proxy_adapter/examples/deployment/config.production.json +0 -12
  159. mcp_proxy_adapter/examples/deployment/config.staging.json +0 -11
  160. mcp_proxy_adapter/examples/deployment/docker-compose.yml +0 -31
  161. mcp_proxy_adapter/examples/deployment/run.sh +0 -43
  162. mcp_proxy_adapter/examples/deployment/run_docker.sh +0 -84
  163. mcp_proxy_adapter/schemas/base_schema.json +0 -114
  164. mcp_proxy_adapter/schemas/openapi_schema.json +0 -314
  165. mcp_proxy_adapter/tests/__init__.py +0 -0
  166. mcp_proxy_adapter/tests/api/__init__.py +0 -3
  167. mcp_proxy_adapter/tests/api/test_cmd_endpoint.py +0 -115
  168. mcp_proxy_adapter/tests/api/test_custom_openapi.py +0 -617
  169. mcp_proxy_adapter/tests/api/test_handlers.py +0 -522
  170. mcp_proxy_adapter/tests/api/test_middleware.py +0 -340
  171. mcp_proxy_adapter/tests/api/test_schemas.py +0 -546
  172. mcp_proxy_adapter/tests/api/test_tool_integration.py +0 -531
  173. mcp_proxy_adapter/tests/commands/__init__.py +0 -3
  174. mcp_proxy_adapter/tests/commands/test_config_command.py +0 -211
  175. mcp_proxy_adapter/tests/commands/test_echo_command.py +0 -127
  176. mcp_proxy_adapter/tests/commands/test_help_command.py +0 -136
  177. mcp_proxy_adapter/tests/conftest.py +0 -131
  178. mcp_proxy_adapter/tests/functional/__init__.py +0 -3
  179. mcp_proxy_adapter/tests/functional/test_api.py +0 -253
  180. mcp_proxy_adapter/tests/integration/__init__.py +0 -3
  181. mcp_proxy_adapter/tests/integration/test_cmd_integration.py +0 -129
  182. mcp_proxy_adapter/tests/integration/test_integration.py +0 -255
  183. mcp_proxy_adapter/tests/performance/__init__.py +0 -3
  184. mcp_proxy_adapter/tests/performance/test_performance.py +0 -189
  185. mcp_proxy_adapter/tests/stubs/__init__.py +0 -10
  186. mcp_proxy_adapter/tests/stubs/echo_command.py +0 -104
  187. mcp_proxy_adapter/tests/test_api_endpoints.py +0 -271
  188. mcp_proxy_adapter/tests/test_api_handlers.py +0 -289
  189. mcp_proxy_adapter/tests/test_base_command.py +0 -123
  190. mcp_proxy_adapter/tests/test_batch_requests.py +0 -117
  191. mcp_proxy_adapter/tests/test_command_registry.py +0 -281
  192. mcp_proxy_adapter/tests/test_config.py +0 -127
  193. mcp_proxy_adapter/tests/test_utils.py +0 -65
  194. mcp_proxy_adapter/tests/unit/__init__.py +0 -3
  195. mcp_proxy_adapter/tests/unit/test_base_command.py +0 -436
  196. mcp_proxy_adapter/tests/unit/test_config.py +0 -217
  197. mcp_proxy_adapter-4.1.1.dist-info/METADATA +0 -200
  198. mcp_proxy_adapter-4.1.1.dist-info/RECORD +0 -110
  199. {mcp_proxy_adapter-4.1.1.dist-info → mcp_proxy_adapter-6.0.1.dist-info}/WHEEL +0 -0
  200. {mcp_proxy_adapter-4.1.1.dist-info → mcp_proxy_adapter-6.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,838 @@
1
+ """
2
+ Command catalog management system.
3
+
4
+ This module handles the command catalog, including:
5
+ - Loading commands from remote plugin servers
6
+ - Version comparison and updates
7
+ - Local command storage and management
8
+ - Automatic dependency installation
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional, Any
17
+ from packaging import version as pkg_version
18
+
19
+ from mcp_proxy_adapter.core.logging import logger
20
+ from mcp_proxy_adapter.commands.dependency_manager import dependency_manager
21
+ from mcp_proxy_adapter.config import config
22
+
23
+ # Try to import requests, but don't fail if not available
24
+ try:
25
+ import requests
26
+ REQUESTS_AVAILABLE = True
27
+ except ImportError:
28
+ REQUESTS_AVAILABLE = False
29
+ logger.warning("requests library not available, HTTP/HTTPS functionality will be limited")
30
+
31
+
32
+ class CommandCatalog:
33
+ """
34
+ Represents a command in the catalog with metadata.
35
+ """
36
+
37
+ def __init__(self, name: str, version: str, source_url: str, file_path: Optional[str] = None):
38
+ self.name = name
39
+ self.version = version
40
+ self.source_url = source_url
41
+ self.file_path = file_path
42
+ self.metadata: Dict[str, Any] = {}
43
+
44
+ # Standard metadata fields
45
+ self.plugin: Optional[str] = None
46
+ self.descr: Optional[str] = None
47
+ self.category: Optional[str] = None
48
+ self.author: Optional[str] = None
49
+ self.email: Optional[str] = None
50
+ self.depends: Optional[List[str]] = None # New field for dependencies
51
+
52
+ def to_dict(self) -> Dict[str, Any]:
53
+ """Convert to dictionary for serialization."""
54
+ return {
55
+ "name": self.name,
56
+ "version": self.version,
57
+ "source_url": self.source_url,
58
+ "file_path": self.file_path,
59
+ "plugin": self.plugin,
60
+ "descr": self.descr,
61
+ "category": self.category,
62
+ "author": self.author,
63
+ "email": self.email,
64
+ "depends": self.depends,
65
+ "metadata": self.metadata
66
+ }
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: Dict[str, Any]) -> 'CommandCatalog':
70
+ """Create from dictionary."""
71
+ catalog = cls(
72
+ name=data["name"],
73
+ version=data["version"],
74
+ source_url=data["source_url"],
75
+ file_path=data.get("file_path")
76
+ )
77
+
78
+ # Load standard metadata fields
79
+ catalog.plugin = data.get("plugin")
80
+ catalog.descr = data.get("descr")
81
+ catalog.category = data.get("category")
82
+ catalog.author = data.get("author")
83
+ catalog.email = data.get("email")
84
+
85
+ # Handle depends field
86
+ depends = data.get("depends")
87
+ if depends and isinstance(depends, list):
88
+ catalog.depends = depends
89
+ elif depends and isinstance(depends, str):
90
+ catalog.depends = [depends]
91
+ else:
92
+ catalog.depends = depends
93
+
94
+ catalog.metadata = data.get("metadata", {})
95
+
96
+ return catalog
97
+
98
+
99
+ class CatalogManager:
100
+ """
101
+ Manages the command catalog system.
102
+
103
+ The catalog is loaded fresh from servers each time - no caching.
104
+ It only contains the list of available plugins.
105
+ """
106
+
107
+ def __init__(self, catalog_dir: str):
108
+ """
109
+ Initialize catalog manager.
110
+
111
+ Args:
112
+ catalog_dir: Directory to store downloaded command files
113
+ """
114
+ self.catalog_dir = Path(catalog_dir)
115
+ self.commands_dir = self.catalog_dir / "commands"
116
+
117
+ # Ensure directories exist
118
+ self.catalog_dir.mkdir(parents=True, exist_ok=True)
119
+ self.commands_dir.mkdir(parents=True, exist_ok=True)
120
+
121
+ # No local catalog caching - always fetch fresh from servers
122
+ self.catalog: Dict[str, CommandCatalog] = {}
123
+
124
+ def _load_catalog(self) -> None:
125
+ """DEPRECATED: Catalog is not cached locally."""
126
+ logger.warning("_load_catalog() is deprecated - catalog is always fetched fresh from servers")
127
+
128
+ def _save_catalog(self) -> None:
129
+ """DEPRECATED: Catalog is not cached locally."""
130
+ logger.warning("_save_catalog() is deprecated - catalog is always fetched fresh from servers")
131
+
132
+ def _parse_catalog_data(self, server_catalog: Dict[str, Any], server_url: str = "http://example.com") -> Dict[str, CommandCatalog]:
133
+ """
134
+ Parse catalog data from server response.
135
+
136
+ Args:
137
+ server_catalog: Raw catalog data from server
138
+ server_url: URL of the server (for constructing source URLs)
139
+
140
+ Returns:
141
+ Dictionary of parsed CommandCatalog objects
142
+ """
143
+ if not isinstance(server_catalog, dict):
144
+ logger.error(f"Invalid catalog format: expected dict, got {type(server_catalog)}")
145
+ return {}
146
+
147
+ result = {}
148
+
149
+ # Support both old format (commands array) and new format (id-value pairs)
150
+ if "commands" in server_catalog:
151
+ # Old format: {"commands": [{"name": "...", ...}]}
152
+ commands_data = server_catalog.get("commands", [])
153
+
154
+ if not isinstance(commands_data, list):
155
+ logger.error(f"Invalid commands format: expected list, got {type(commands_data)}")
156
+ return {}
157
+
158
+ for cmd_data in commands_data:
159
+ try:
160
+ if not isinstance(cmd_data, dict):
161
+ logger.warning(f"Skipping invalid command data: expected dict, got {type(cmd_data)}")
162
+ continue
163
+
164
+ name = cmd_data.get("name")
165
+ if not name or not isinstance(name, str):
166
+ logger.warning(f"Skipping command without valid name")
167
+ continue
168
+
169
+ version = cmd_data.get("version", "0.1")
170
+ if not isinstance(version, str):
171
+ logger.warning(f"Invalid version format for {name}: {version}")
172
+ version = "0.1"
173
+
174
+ source_url = cmd_data.get("source_url", "")
175
+ if not isinstance(source_url, str):
176
+ logger.warning(f"Invalid source_url for {name}: {source_url}")
177
+ source_url = ""
178
+
179
+ catalog = CommandCatalog(
180
+ name=name,
181
+ version=version,
182
+ source_url=source_url
183
+ )
184
+
185
+ # Load standard metadata fields
186
+ catalog.plugin = cmd_data.get("plugin")
187
+ catalog.descr = cmd_data.get("descr")
188
+ catalog.category = cmd_data.get("category")
189
+ catalog.author = cmd_data.get("author")
190
+ catalog.email = cmd_data.get("email")
191
+ catalog.metadata = cmd_data
192
+
193
+ result[name] = catalog
194
+
195
+ except Exception as e:
196
+ logger.error(f"Error processing command data: {e}")
197
+ continue
198
+ else:
199
+ # New format: {"id": {"plugin": "...", "descr": "...", ...}}
200
+ for command_id, cmd_data in server_catalog.items():
201
+ try:
202
+ if not isinstance(cmd_data, dict):
203
+ logger.warning(f"Skipping invalid command data for {command_id}: expected dict, got {type(cmd_data)}")
204
+ continue
205
+
206
+ # Use command_id as name if name is not provided
207
+ name = cmd_data.get("name", command_id)
208
+ if not isinstance(name, str):
209
+ logger.warning(f"Skipping command {command_id} without valid name")
210
+ continue
211
+
212
+ version = cmd_data.get("version", "0.1")
213
+ if not isinstance(version, str):
214
+ logger.warning(f"Invalid version format for {name}: {version}")
215
+ version = "0.1"
216
+
217
+ # For new format, construct source_url from server_url and plugin filename
218
+ plugin_file = cmd_data.get("plugin")
219
+ if plugin_file and isinstance(plugin_file, str):
220
+ # Construct source URL by appending plugin filename to server URL
221
+ if server_url.endswith('/'):
222
+ source_url = f"{server_url}{plugin_file}"
223
+ else:
224
+ source_url = f"{server_url}/{plugin_file}"
225
+ else:
226
+ source_url = server_url
227
+
228
+ catalog = CommandCatalog(
229
+ name=name,
230
+ version=version,
231
+ source_url=source_url
232
+ )
233
+
234
+ # Load standard metadata fields
235
+ catalog.plugin = cmd_data.get("plugin")
236
+ catalog.descr = cmd_data.get("descr")
237
+ catalog.category = cmd_data.get("category")
238
+ catalog.author = cmd_data.get("author")
239
+ catalog.email = cmd_data.get("email")
240
+
241
+ # Load dependencies field
242
+ depends = cmd_data.get("depends")
243
+ if depends and isinstance(depends, list):
244
+ catalog.depends = depends
245
+ elif depends and isinstance(depends, str):
246
+ # Handle case where depends is a single string
247
+ catalog.depends = [depends]
248
+
249
+ # Store full metadata including new fields like "depends"
250
+ catalog.metadata = cmd_data
251
+
252
+ result[name] = catalog
253
+
254
+ except Exception as e:
255
+ logger.error(f"Error processing command {command_id}: {e}")
256
+ continue
257
+
258
+ return result
259
+
260
+ def get_catalog_from_server(self, server_url: str) -> Dict[str, CommandCatalog]:
261
+ """
262
+ Fetch command catalog from remote server.
263
+
264
+ Args:
265
+ server_url: URL of the plugin server
266
+
267
+ Returns:
268
+ Dictionary of commands available on the server
269
+ """
270
+ if not REQUESTS_AVAILABLE:
271
+ logger.error("requests library not available, cannot fetch catalog from remote server")
272
+ return {}
273
+
274
+ try:
275
+ # Validate URL format
276
+ if not server_url.startswith(('http://', 'https://')):
277
+ logger.error(f"Invalid server URL format: {server_url}")
278
+ return {}
279
+
280
+ # Fetch catalog from server (use URL as-is)
281
+ logger.debug(f"Fetching catalog from: {server_url}")
282
+
283
+ response = requests.get(server_url, timeout=30)
284
+ response.raise_for_status()
285
+
286
+ # Validate response content
287
+ if not response.content:
288
+ logger.error(f"Empty response from {server_url}")
289
+ return {}
290
+
291
+ try:
292
+ server_catalog = response.json()
293
+ except json.JSONDecodeError as e:
294
+ logger.error(f"Invalid JSON response from {server_url}: {e}")
295
+ return {}
296
+
297
+ if not isinstance(server_catalog, dict):
298
+ logger.error(f"Invalid catalog format from {server_url}: expected dict, got {type(server_catalog)}")
299
+ return {}
300
+
301
+ result = self._parse_catalog_data(server_catalog, server_url)
302
+
303
+ logger.info(f"Successfully fetched catalog from {server_url}: {len(result)} commands")
304
+ return result
305
+
306
+ except requests.exceptions.Timeout:
307
+ logger.error(f"Timeout while fetching catalog from {server_url}")
308
+ return {}
309
+ except requests.exceptions.ConnectionError as e:
310
+ logger.error(f"Connection error while fetching catalog from {server_url}: {e}")
311
+ return {}
312
+ except requests.exceptions.HTTPError as e:
313
+ logger.error(f"HTTP error while fetching catalog from {server_url}: {e}")
314
+ return {}
315
+ except requests.exceptions.RequestException as e:
316
+ logger.error(f"Request error while fetching catalog from {server_url}: {e}")
317
+ return {}
318
+ except Exception as e:
319
+ logger.error(f"Unexpected error while fetching catalog from {server_url}: {e}")
320
+ return {}
321
+
322
+ def _check_dependencies(self, command_name: str, server_cmd: CommandCatalog) -> bool:
323
+ """
324
+ Check if command dependencies are satisfied.
325
+
326
+ Args:
327
+ command_name: Name of the command
328
+ server_cmd: Command catalog entry from server
329
+
330
+ Returns:
331
+ True if dependencies are satisfied, False otherwise
332
+ """
333
+ if not server_cmd.depends:
334
+ return True
335
+
336
+ all_satisfied, missing_deps, installed_deps = dependency_manager.check_dependencies(server_cmd.depends)
337
+
338
+ if not all_satisfied:
339
+ logger.warning(f"Command {command_name} has missing dependencies: {missing_deps}")
340
+ logger.info(f"Installed dependencies: {installed_deps}")
341
+ return False
342
+
343
+ logger.debug(f"All dependencies satisfied for command {command_name}: {server_cmd.depends}")
344
+ return True
345
+
346
+ def _install_dependencies(self, command_name: str, server_cmd: CommandCatalog, auto_install: bool = None) -> bool:
347
+ """
348
+ Install command dependencies automatically.
349
+
350
+ Args:
351
+ command_name: Name of the command
352
+ server_cmd: Command catalog entry from server
353
+ auto_install: Whether to automatically install missing dependencies (None = use config)
354
+
355
+ Returns:
356
+ True if all dependencies are satisfied, False otherwise
357
+ """
358
+ if not server_cmd.depends:
359
+ return True
360
+
361
+ # Use config setting if auto_install is None
362
+ if auto_install is None:
363
+ auto_install = config.get("commands.auto_install_dependencies", True)
364
+
365
+ # Check current dependencies
366
+ all_satisfied, missing_deps, installed_deps = dependency_manager.check_dependencies(server_cmd.depends)
367
+
368
+ if all_satisfied:
369
+ logger.info(f"All dependencies already satisfied for {command_name}: {server_cmd.depends}")
370
+ return True
371
+
372
+ if not auto_install:
373
+ logger.warning(f"Command {command_name} has missing dependencies: {missing_deps}")
374
+ logger.info(f"Auto-install is disabled. Please install manually: pip install {' '.join(missing_deps)}")
375
+ return False
376
+
377
+ # Try to install missing dependencies
378
+ logger.info(f"Installing missing dependencies for {command_name}: {missing_deps}")
379
+
380
+ success, installed_deps, failed_deps = dependency_manager.install_dependencies(missing_deps)
381
+
382
+ if success:
383
+ logger.info(f"Successfully installed all dependencies for {command_name}: {installed_deps}")
384
+
385
+ # Verify installation
386
+ all_verified, failed_verifications = dependency_manager.verify_installation(server_cmd.depends)
387
+ if all_verified:
388
+ logger.info(f"All dependencies verified for {command_name}")
389
+ return True
390
+ else:
391
+ logger.error(f"Failed to verify dependencies for {command_name}: {failed_verifications}")
392
+ return False
393
+ else:
394
+ logger.error(f"Failed to install dependencies for {command_name}: {failed_deps}")
395
+ logger.error(f"Please install manually: pip install {' '.join(failed_deps)}")
396
+ return False
397
+
398
+ def _should_download_command(self, command_name: str, server_cmd: CommandCatalog) -> bool:
399
+ """
400
+ Check if command should be downloaded based on version comparison.
401
+
402
+ Args:
403
+ command_name: Name of the command
404
+ server_cmd: Command catalog entry from server
405
+
406
+ Returns:
407
+ True if command should be downloaded, False otherwise
408
+ """
409
+ local_file = self.commands_dir / f"{command_name}_command.py"
410
+
411
+ # If local file doesn't exist, download
412
+ if not local_file.exists():
413
+ logger.info(f"New command found: {command_name} v{server_cmd.version}")
414
+ return True
415
+
416
+ # Try to extract version from local file
417
+ try:
418
+ local_metadata = self.extract_metadata_from_file(str(local_file))
419
+ local_version = local_metadata.get("version", "0.0")
420
+
421
+ # Compare versions
422
+ server_ver = pkg_version.parse(server_cmd.version)
423
+ local_ver = pkg_version.parse(local_version)
424
+
425
+ if server_ver > local_ver:
426
+ logger.info(f"Newer version available for {command_name}: {local_ver} -> {server_ver}")
427
+ return True
428
+ else:
429
+ logger.debug(f"Local version {local_ver} is same or newer than server version {server_ver}")
430
+ return False
431
+
432
+ except Exception as e:
433
+ logger.warning(f"Failed to compare versions for {command_name}: {e}")
434
+ # If version comparison fails, download anyway
435
+ return True
436
+
437
+ def update_command(self, command_name: str, server_catalog: Dict[str, CommandCatalog]) -> bool:
438
+ """
439
+ DEPRECATED: Always download fresh from server.
440
+
441
+ Args:
442
+ command_name: Name of the command to update
443
+ server_catalog: Catalog from server
444
+
445
+ Returns:
446
+ True if command was downloaded, False otherwise
447
+ """
448
+ logger.warning("update_command() is deprecated - always downloading fresh from server (use _download_command directly)")
449
+
450
+ if command_name not in server_catalog:
451
+ return False
452
+
453
+ server_cmd = server_catalog[command_name]
454
+ return self._download_command(command_name, server_cmd)
455
+
456
+ def _download_command(self, command_name: str, server_cmd: CommandCatalog) -> bool:
457
+ """
458
+ Download command file from server.
459
+
460
+ Args:
461
+ command_name: Name of the command
462
+ server_cmd: Command catalog entry from server
463
+
464
+ Returns:
465
+ True if download successful, False otherwise
466
+ """
467
+ if not REQUESTS_AVAILABLE:
468
+ logger.error("requests library not available, cannot download command from remote server")
469
+ return False
470
+
471
+ # Step 1: Check and install dependencies
472
+ if not self._install_dependencies(command_name, server_cmd):
473
+ logger.error(f"Cannot download {command_name}: failed to install dependencies")
474
+ return False
475
+
476
+ try:
477
+ # Validate source URL
478
+ if not server_cmd.source_url.startswith(('http://', 'https://')):
479
+ logger.error(f"Invalid source URL for {command_name}: {server_cmd.source_url}")
480
+ return False
481
+
482
+ # Download command file (use source_url as-is)
483
+ logger.debug(f"Downloading {command_name} from: {server_cmd.source_url}")
484
+
485
+ response = requests.get(server_cmd.source_url, timeout=30)
486
+ response.raise_for_status()
487
+
488
+ # Validate response content
489
+ if not response.content:
490
+ logger.error(f"Empty response when downloading {command_name} from {server_cmd.source_url}")
491
+ return False
492
+
493
+ # Validate Python file content
494
+ content = response.text
495
+ if not content.strip():
496
+ logger.error(f"Empty file content for {command_name} from {server_cmd.source_url}")
497
+ return False
498
+
499
+ # Basic Python file validation - only warn for clearly invalid files
500
+ content_stripped = content.strip()
501
+ if (content_stripped and
502
+ not content_stripped.startswith(('"""', "'''", '#', 'from ', 'import ', 'class ', 'def ')) and
503
+ not any(keyword in content_stripped for keyword in ['class', 'def', 'import', 'from', '"""', "'''", '#'])):
504
+ logger.warning(f"File {command_name}_command.py doesn't look like a valid Python file")
505
+
506
+ # Create temporary file first for validation
507
+ temp_file = None
508
+ try:
509
+ temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False)
510
+ temp_file.write(content)
511
+ temp_file.close()
512
+
513
+ # Try to import the module to validate it
514
+ try:
515
+ import importlib.util
516
+ spec = importlib.util.spec_from_file_location(f"{command_name}_command", temp_file.name)
517
+ if spec and spec.loader:
518
+ module = importlib.util.module_from_spec(spec)
519
+ spec.loader.exec_module(module)
520
+
521
+ # Check if module contains a Command class
522
+ if not hasattr(module, 'Command') and not any(
523
+ hasattr(module, attr) and attr.endswith('Command')
524
+ for attr in dir(module)
525
+ ):
526
+ logger.warning(f"Module {command_name}_command.py doesn't contain a Command class")
527
+
528
+ except Exception as e:
529
+ logger.error(f"Failed to validate {command_name}_command.py: {e}")
530
+ return False
531
+
532
+ # If validation passed, move to final location
533
+ file_path = self.commands_dir / f"{command_name}_command.py"
534
+
535
+ # Remove existing file if it exists
536
+ if file_path.exists():
537
+ file_path.unlink()
538
+
539
+ # Move temporary file to final location
540
+ shutil.move(temp_file.name, file_path)
541
+
542
+ # Update catalog entry
543
+ server_cmd.file_path = str(file_path)
544
+ self.catalog[command_name] = server_cmd
545
+
546
+ # Extract metadata from downloaded file
547
+ try:
548
+ metadata = self.extract_metadata_from_file(str(file_path))
549
+ if metadata:
550
+ # Update standard fields
551
+ if 'plugin' in metadata:
552
+ server_cmd.plugin = metadata['plugin']
553
+ if 'descr' in metadata:
554
+ server_cmd.descr = metadata['descr']
555
+ if 'category' in metadata:
556
+ server_cmd.category = metadata['category']
557
+ if 'author' in metadata:
558
+ server_cmd.author = metadata['author']
559
+ if 'email' in metadata:
560
+ server_cmd.email = metadata['email']
561
+ if 'version' in metadata:
562
+ server_cmd.version = metadata['version']
563
+
564
+ # Update full metadata
565
+ server_cmd.metadata.update(metadata)
566
+
567
+ logger.debug(f"Extracted metadata for {command_name}: {metadata}")
568
+ except Exception as e:
569
+ logger.warning(f"Failed to extract metadata from {command_name}: {e}")
570
+
571
+ logger.info(f"Successfully downloaded and validated {command_name} v{server_cmd.version}")
572
+ return True
573
+
574
+ finally:
575
+ # Clean up temporary file if it still exists
576
+ if temp_file and os.path.exists(temp_file.name):
577
+ try:
578
+ os.unlink(temp_file.name)
579
+ except Exception as e:
580
+ logger.warning(f"Failed to clean up temporary file {temp_file.name}: {e}")
581
+
582
+ except requests.exceptions.Timeout:
583
+ logger.error(f"Timeout while downloading {command_name} from {server_cmd.source_url}")
584
+ return False
585
+ except requests.exceptions.ConnectionError as e:
586
+ logger.error(f"Connection error while downloading {command_name} from {server_cmd.source_url}: {e}")
587
+ return False
588
+ except requests.exceptions.HTTPError as e:
589
+ logger.error(f"HTTP error while downloading {command_name} from {server_cmd.source_url}: {e}")
590
+ return False
591
+ except requests.exceptions.RequestException as e:
592
+ logger.error(f"Request error while downloading {command_name} from {server_cmd.source_url}: {e}")
593
+ return False
594
+ except OSError as e:
595
+ logger.error(f"File system error while downloading {command_name}: {e}")
596
+ return False
597
+ except Exception as e:
598
+ logger.error(f"Unexpected error while downloading {command_name}: {e}")
599
+ return False
600
+
601
+ def sync_with_servers(self, server_urls: List[str]) -> Dict[str, Any]:
602
+ """
603
+ Sync with remote servers - load fresh catalog each time.
604
+
605
+ Args:
606
+ server_urls: List of server URLs to sync with
607
+
608
+ Returns:
609
+ Dictionary with sync results
610
+ """
611
+ logger.info(f"Loading fresh catalog from {len(server_urls)} servers")
612
+
613
+ # Clear local catalog - always start fresh
614
+ self.catalog = {}
615
+
616
+ results = {
617
+ "servers_processed": 0,
618
+ "commands_updated": 0,
619
+ "commands_added": 0,
620
+ "errors": []
621
+ }
622
+
623
+ for server_url in server_urls:
624
+ try:
625
+ # Get fresh catalog from server
626
+ server_catalog = self.get_catalog_from_server(server_url)
627
+ if not server_catalog:
628
+ continue
629
+
630
+ results["servers_processed"] += 1
631
+
632
+ # Process each command from server catalog
633
+ for command_name, server_cmd in server_catalog.items():
634
+ # Check if we need to download/update
635
+ if self._should_download_command(command_name, server_cmd):
636
+ if self._download_command(command_name, server_cmd):
637
+ results["commands_added"] += 1
638
+ # Add to local catalog for this session
639
+ self.catalog[command_name] = server_cmd
640
+ else:
641
+ # Command already exists with same or newer version
642
+ logger.debug(f"Command {command_name} already exists with same or newer version")
643
+
644
+ except Exception as e:
645
+ error_msg = f"Failed to sync with {server_url}: {e}"
646
+ results["errors"].append(error_msg)
647
+ logger.error(error_msg)
648
+
649
+ logger.info(f"Fresh catalog loaded: {results}")
650
+ return results
651
+
652
+ def get_local_commands(self) -> List[str]:
653
+ """
654
+ Get list of locally available command files.
655
+
656
+ Returns:
657
+ List of command file paths
658
+ """
659
+ commands = []
660
+ for file_path in self.commands_dir.glob("*_command.py"):
661
+ commands.append(str(file_path))
662
+
663
+ logger.debug(f"Found {len(commands)} local command files: {[Path(c).name for c in commands]}")
664
+ return commands
665
+
666
+ def get_command_info(self, command_name: str) -> Optional[CommandCatalog]:
667
+ """
668
+ Get information about a command in the catalog.
669
+
670
+ Args:
671
+ command_name: Name of the command
672
+
673
+ Returns:
674
+ Command catalog entry or None if not found
675
+ """
676
+ return self.catalog.get(command_name)
677
+
678
+ def remove_command(self, command_name: str) -> bool:
679
+ """
680
+ Remove command from catalog and delete file.
681
+
682
+ Args:
683
+ command_name: Name of the command to remove
684
+
685
+ Returns:
686
+ True if removed successfully, False otherwise
687
+ """
688
+ if command_name not in self.catalog:
689
+ return False
690
+
691
+ try:
692
+ # Remove file
693
+ cmd = self.catalog[command_name]
694
+ if cmd.file_path and os.path.exists(cmd.file_path):
695
+ os.remove(cmd.file_path)
696
+
697
+ # Remove from catalog
698
+ del self.catalog[command_name]
699
+ self._save_catalog()
700
+
701
+ logger.info(f"Removed command: {command_name}")
702
+ return True
703
+
704
+ except Exception as e:
705
+ logger.error(f"Failed to remove command {command_name}: {e}")
706
+ return False
707
+
708
+ def extract_metadata_from_file(self, file_path: str) -> Dict[str, Any]:
709
+ """
710
+ Extract metadata from command file.
711
+
712
+ Args:
713
+ file_path: Path to command file
714
+
715
+ Returns:
716
+ Dictionary with extracted metadata
717
+ """
718
+ metadata = {}
719
+
720
+ try:
721
+ with open(file_path, 'r', encoding='utf-8') as f:
722
+ content = f.read()
723
+
724
+ # Look for metadata in docstring or comments
725
+ lines = content.split('\n')
726
+
727
+ for line in lines:
728
+ line = line.strip()
729
+
730
+ # Look for metadata patterns
731
+ if line.startswith('#') or line.startswith('"""') or line.startswith("'''"):
732
+ # Try to parse JSON-like metadata
733
+ if '{' in line and '}' in line:
734
+ try:
735
+ # Extract JSON part
736
+ start = line.find('{')
737
+ end = line.rfind('}') + 1
738
+ json_str = line[start:end]
739
+
740
+ # Parse JSON
741
+ import json
742
+ parsed = json.loads(json_str)
743
+
744
+ # Update metadata
745
+ metadata.update(parsed)
746
+
747
+ except json.JSONDecodeError:
748
+ # Not valid JSON, continue
749
+ continue
750
+
751
+ # Look for specific metadata patterns
752
+ for pattern in ['plugin:', 'descr:', 'category:', 'author:', 'version:', 'email:']:
753
+ if pattern in line:
754
+ key = pattern.rstrip(':')
755
+ value = line.split(pattern, 1)[1].strip().strip('"\'')
756
+ metadata[key] = value
757
+
758
+ # Also check for JSON in docstring blocks
759
+ content_str = content
760
+ if '"""' in content_str or "'''" in content_str:
761
+ # Find docstring blocks
762
+ import re
763
+ docstring_pattern = r'"""(.*?)"""|\'\'\'(.*?)\'\'\''
764
+ matches = re.findall(docstring_pattern, content_str, re.DOTALL)
765
+
766
+ for match in matches:
767
+ docstring = match[0] if match[0] else match[1]
768
+ # Look for JSON in docstring
769
+ if '{' in docstring and '}' in docstring:
770
+ try:
771
+ # Find JSON object
772
+ start = docstring.find('{')
773
+ end = docstring.rfind('}') + 1
774
+ json_str = docstring[start:end]
775
+
776
+ import json
777
+ parsed = json.loads(json_str)
778
+ metadata.update(parsed)
779
+
780
+ except json.JSONDecodeError:
781
+ continue
782
+
783
+ logger.debug(f"Extracted metadata from {file_path}: {metadata}")
784
+ return metadata
785
+
786
+ except Exception as e:
787
+ logger.error(f"Failed to extract metadata from {file_path}: {e}")
788
+ return {}
789
+
790
+ def update_local_command_metadata(self, command_name: str) -> bool:
791
+ """
792
+ Update metadata for a local command by reading its file.
793
+
794
+ Args:
795
+ command_name: Name of the command
796
+
797
+ Returns:
798
+ True if updated successfully, False otherwise
799
+ """
800
+ if command_name not in self.catalog:
801
+ return False
802
+
803
+ cmd = self.catalog[command_name]
804
+ if not cmd.file_path or not os.path.exists(cmd.file_path):
805
+ return False
806
+
807
+ try:
808
+ metadata = self.extract_metadata_from_file(cmd.file_path)
809
+
810
+ if metadata:
811
+ # Update standard fields
812
+ if 'plugin' in metadata:
813
+ cmd.plugin = metadata['plugin']
814
+ if 'descr' in metadata:
815
+ cmd.descr = metadata['descr']
816
+ if 'category' in metadata:
817
+ cmd.category = metadata['category']
818
+ if 'author' in metadata:
819
+ cmd.author = metadata['author']
820
+ if 'email' in metadata:
821
+ cmd.email = metadata['email']
822
+ if 'version' in metadata:
823
+ cmd.version = metadata['version']
824
+
825
+ # Update full metadata
826
+ cmd.metadata.update(metadata)
827
+
828
+ # Save catalog
829
+ self._save_catalog()
830
+
831
+ logger.info(f"Updated metadata for {command_name}")
832
+ return True
833
+
834
+ return False
835
+
836
+ except Exception as e:
837
+ logger.error(f"Failed to update metadata for {command_name}: {e}")
838
+ return False