iflow-mcp_alpadalar-active-directory-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """
2
+ ActiveDirectoryMCP - Model Context Protocol server for Active Directory management.
3
+
4
+ This package provides a comprehensive MCP server for interacting with Active Directory
5
+ through LDAP protocol, offering tools for user management, group operations,
6
+ organizational unit management, and security operations.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+ __author__ = "Alperen Adalar"
11
+ __email__ = "alp.adalar@gmail.com"
12
+
13
+ from .server import ActiveDirectoryMCPServer
14
+
15
+ __all__ = ["ActiveDirectoryMCPServer"]
@@ -0,0 +1,21 @@
1
+ """Configuration module for Active Directory MCP."""
2
+
3
+ from .loader import load_config
4
+ from .models import (
5
+ ActiveDirectoryConfig,
6
+ OrganizationalUnitsConfig,
7
+ SecurityConfig,
8
+ LoggingConfig,
9
+ PerformanceConfig,
10
+ Config,
11
+ )
12
+
13
+ __all__ = [
14
+ "load_config",
15
+ "ActiveDirectoryConfig",
16
+ "OrganizationalUnitsConfig",
17
+ "SecurityConfig",
18
+ "LoggingConfig",
19
+ "PerformanceConfig",
20
+ "Config",
21
+ ]
@@ -0,0 +1,101 @@
1
+ """Configuration loader for Active Directory MCP."""
2
+
3
+ import json
4
+ import os
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .models import Config
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def load_config(config_path: Optional[str] = None) -> Config:
15
+ """
16
+ Load configuration from JSON file.
17
+
18
+ Args:
19
+ config_path: Path to configuration file. If None, uses AD_MCP_CONFIG
20
+ environment variable.
21
+
22
+ Returns:
23
+ Config: Loaded and validated configuration
24
+
25
+ Raises:
26
+ FileNotFoundError: If config file doesn't exist
27
+ ValueError: If config is invalid
28
+ json.JSONDecodeError: If config file is not valid JSON
29
+ """
30
+ # Determine config file path
31
+ if config_path is None:
32
+ config_path = os.getenv("AD_MCP_CONFIG")
33
+ if not config_path:
34
+ raise ValueError(
35
+ "No configuration file specified. Either provide config_path or set AD_MCP_CONFIG environment variable."
36
+ )
37
+
38
+ config_file = Path(config_path)
39
+ if not config_file.exists():
40
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
41
+
42
+ logger.info(f"Loading configuration from: {config_path}")
43
+
44
+ try:
45
+ with open(config_file, 'r', encoding='utf-8') as f:
46
+ config_data = json.load(f)
47
+
48
+ # Validate and create config object
49
+ config = Config(**config_data)
50
+ logger.info("Configuration loaded successfully")
51
+
52
+ # Log configuration summary (without sensitive data)
53
+ logger.debug(f"AD Server: {config.active_directory.server}")
54
+ logger.debug(f"Domain: {config.active_directory.domain}")
55
+ logger.debug(f"Base DN: {config.active_directory.base_dn}")
56
+ logger.debug(f"SSL Enabled: {config.active_directory.use_ssl}")
57
+
58
+ return config
59
+
60
+ except json.JSONDecodeError as e:
61
+ logger.error(f"Invalid JSON in configuration file: {e}")
62
+ raise
63
+ except Exception as e:
64
+ logger.error(f"Error loading configuration: {e}")
65
+ raise
66
+
67
+
68
+ def validate_config(config: Config) -> None:
69
+ """
70
+ Perform additional validation on configuration.
71
+
72
+ Args:
73
+ config: Configuration to validate
74
+
75
+ Raises:
76
+ ValueError: If configuration is invalid
77
+ """
78
+ # Check if required OUs are under base DN
79
+ base_dn = config.active_directory.base_dn.lower()
80
+
81
+ ous = [
82
+ config.organizational_units.users_ou,
83
+ config.organizational_units.groups_ou,
84
+ config.organizational_units.computers_ou,
85
+ config.organizational_units.service_accounts_ou,
86
+ ]
87
+
88
+ for ou in ous:
89
+ if not ou.lower().endswith(base_dn):
90
+ logger.warning(f"OU {ou} is not under base DN {config.active_directory.base_dn}")
91
+
92
+ # Validate bind DN
93
+ if not config.active_directory.bind_dn.lower().endswith(base_dn):
94
+ logger.warning(f"Bind DN {config.active_directory.bind_dn} is not under base DN")
95
+
96
+ # Check SSL configuration
97
+ if config.active_directory.use_ssl and config.security.enable_tls:
98
+ if not config.active_directory.server.startswith('ldaps://'):
99
+ logger.warning("SSL enabled but server URL doesn't use ldaps://")
100
+
101
+ logger.info("Configuration validation completed")
@@ -0,0 +1,101 @@
1
+ """Configuration models for Active Directory MCP."""
2
+
3
+ from typing import List, Optional
4
+ from pydantic import BaseModel, Field, field_validator
5
+
6
+
7
+ class ActiveDirectoryConfig(BaseModel):
8
+ """Active Directory connection configuration."""
9
+
10
+ server: str = Field(..., description="Primary LDAP server URL")
11
+ server_pool: Optional[List[str]] = Field(default=None, description="Additional LDAP servers for redundancy")
12
+ use_ssl: bool = Field(default=True, description="Use SSL/TLS connection")
13
+ ssl_port: int = Field(default=636, description="SSL port for LDAP")
14
+ domain: str = Field(..., description="Active Directory domain")
15
+ base_dn: str = Field(..., description="Base Distinguished Name")
16
+ bind_dn: str = Field(..., description="Service account DN for binding")
17
+ password: str = Field(..., description="Service account password")
18
+ timeout: int = Field(default=30, description="Connection timeout in seconds")
19
+ auto_bind: bool = Field(default=True, description="Automatically bind on connection")
20
+ receive_timeout: int = Field(default=10, description="Receive timeout in seconds")
21
+
22
+ @field_validator('server')
23
+ @classmethod
24
+ def validate_server(cls, v):
25
+ """Validate server URL format."""
26
+ if not v.startswith(('ldap://', 'ldaps://')):
27
+ raise ValueError('Server must start with ldap:// or ldaps://')
28
+ return v
29
+
30
+
31
+ class OrganizationalUnitsConfig(BaseModel):
32
+ """Organizational Units configuration."""
33
+
34
+ users_ou: str = Field(..., description="Users organizational unit DN")
35
+ groups_ou: str = Field(..., description="Groups organizational unit DN")
36
+ computers_ou: str = Field(..., description="Computers organizational unit DN")
37
+ service_accounts_ou: str = Field(..., description="Service accounts organizational unit DN")
38
+
39
+
40
+ class SecurityConfig(BaseModel):
41
+ """Security configuration for LDAP connections."""
42
+
43
+ enable_tls: bool = Field(default=True, description="Enable TLS encryption")
44
+ validate_certificate: bool = Field(default=True, description="Validate server certificate")
45
+ ca_cert_file: Optional[str] = Field(default=None, description="CA certificate file path")
46
+ require_secure_connection: bool = Field(default=True, description="Require secure connection")
47
+
48
+
49
+ class LoggingConfig(BaseModel):
50
+ """Logging configuration."""
51
+
52
+ level: str = Field(default="INFO", description="Logging level")
53
+ format: str = Field(
54
+ default="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
55
+ description="Log message format"
56
+ )
57
+ file: Optional[str] = Field(default=None, description="Log file path")
58
+
59
+ @field_validator('level')
60
+ @classmethod
61
+ def validate_level(cls, v):
62
+ """Validate logging level."""
63
+ valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
64
+ if v.upper() not in valid_levels:
65
+ raise ValueError(f'Level must be one of: {valid_levels}')
66
+ return v.upper()
67
+
68
+
69
+ class PerformanceConfig(BaseModel):
70
+ """Performance configuration."""
71
+
72
+ connection_pool_size: int = Field(default=10, description="Connection pool size")
73
+ max_retries: int = Field(default=3, description="Maximum connection retries")
74
+ retry_delay: float = Field(default=1.0, description="Retry delay in seconds")
75
+ page_size: int = Field(default=1000, description="LDAP search page size")
76
+
77
+ @field_validator('connection_pool_size', 'max_retries', 'page_size')
78
+ @classmethod
79
+ def validate_positive_int(cls, v):
80
+ """Validate positive integers."""
81
+ if v <= 0:
82
+ raise ValueError('Value must be positive')
83
+ return v
84
+
85
+ @field_validator('retry_delay')
86
+ @classmethod
87
+ def validate_positive_float(cls, v):
88
+ """Validate positive float."""
89
+ if v <= 0:
90
+ raise ValueError('Retry delay must be positive')
91
+ return v
92
+
93
+
94
+ class Config(BaseModel):
95
+ """Main configuration class."""
96
+
97
+ active_directory: ActiveDirectoryConfig
98
+ organizational_units: OrganizationalUnitsConfig
99
+ security: SecurityConfig = Field(default_factory=SecurityConfig)
100
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
101
+ performance: PerformanceConfig = Field(default_factory=PerformanceConfig)
@@ -0,0 +1,6 @@
1
+ """Core functionality for Active Directory MCP."""
2
+
3
+ from .ldap_manager import LDAPManager
4
+ from .logging import setup_logging
5
+
6
+ __all__ = ["LDAPManager", "setup_logging"]
@@ -0,0 +1,423 @@
1
+ """LDAP connection manager for Active Directory."""
2
+
3
+ import logging
4
+ import ssl
5
+ import time
6
+ from typing import Optional, List, Dict, Any, Union
7
+ from threading import Lock
8
+
9
+ import ldap3
10
+ from ldap3 import Server, Connection, ALL, SUBTREE, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES
11
+ from ldap3.core.exceptions import LDAPException, LDAPBindError, LDAPSocketOpenError
12
+
13
+ from ..config.models import ActiveDirectoryConfig, SecurityConfig, PerformanceConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class LDAPManager:
19
+ """
20
+ LDAP connection manager for Active Directory operations.
21
+
22
+ Provides connection pooling, automatic reconnection, and error handling
23
+ for LDAP operations against Active Directory.
24
+ """
25
+
26
+ def __init__(self,
27
+ ad_config: ActiveDirectoryConfig,
28
+ security_config: SecurityConfig,
29
+ performance_config: PerformanceConfig):
30
+ """
31
+ Initialize LDAP manager.
32
+
33
+ Args:
34
+ ad_config: Active Directory configuration
35
+ security_config: Security configuration
36
+ performance_config: Performance configuration
37
+ """
38
+ self.ad_config = ad_config
39
+ self.security_config = security_config
40
+ self.performance_config = performance_config
41
+
42
+ self._connection: Optional[Connection] = None
43
+ self._server_pool: Optional[List[Server]] = None
44
+ self._lock = Lock()
45
+
46
+ self._setup_servers()
47
+
48
+ def _setup_servers(self) -> None:
49
+ """Setup LDAP servers and server pool."""
50
+ try:
51
+ # Setup TLS configuration
52
+ tls_config = None
53
+ if self.security_config.enable_tls:
54
+ tls_config = ldap3.Tls(
55
+ validate=ssl.CERT_REQUIRED if self.security_config.validate_certificate else ssl.CERT_NONE,
56
+ ca_certs_file=self.security_config.ca_cert_file
57
+ )
58
+
59
+ # Create primary server
60
+ primary_server = Server(
61
+ self.ad_config.server,
62
+ get_info=ALL,
63
+ tls=tls_config,
64
+ connect_timeout=self.ad_config.timeout
65
+ )
66
+
67
+ servers = [primary_server]
68
+
69
+ # Add additional servers from pool
70
+ if self.ad_config.server_pool:
71
+ for server_url in self.ad_config.server_pool:
72
+ server = Server(
73
+ server_url,
74
+ get_info=ALL,
75
+ tls=tls_config,
76
+ connect_timeout=self.ad_config.timeout
77
+ )
78
+ servers.append(server)
79
+
80
+ # Create server pool for failover
81
+ self._server_pool = servers
82
+ logger.info(f"Configured {len(servers)} LDAP servers")
83
+
84
+ except Exception as e:
85
+ logger.error(f"Error setting up LDAP servers: {e}")
86
+ raise
87
+
88
+ def connect(self) -> Connection:
89
+ """
90
+ Establish LDAP connection with retry logic.
91
+
92
+ Returns:
93
+ Connection: Active LDAP connection
94
+
95
+ Raises:
96
+ LDAPException: If connection fails after all retries
97
+ """
98
+ with self._lock:
99
+ if self._connection and self._connection.bound:
100
+ return self._connection
101
+
102
+ last_error = None
103
+
104
+ for attempt in range(self.performance_config.max_retries):
105
+ try:
106
+ # Try each server in the pool
107
+ for server in self._server_pool:
108
+ try:
109
+ logger.debug(f"Attempting connection to {server.host}:{server.port}")
110
+
111
+ connection = Connection(
112
+ server,
113
+ user=self.ad_config.bind_dn,
114
+ password=self.ad_config.password,
115
+ auto_bind=self.ad_config.auto_bind,
116
+ receive_timeout=self.ad_config.receive_timeout,
117
+ authentication=ldap3.SIMPLE,
118
+ check_names=True,
119
+ raise_exceptions=True
120
+ )
121
+
122
+ # Test the connection
123
+ if connection.bind():
124
+ self._connection = connection
125
+ logger.info(f"Successfully connected to {server.host}:{server.port}")
126
+ return connection
127
+ else:
128
+ logger.warning(f"Failed to bind to {server.host}:{server.port}")
129
+
130
+ except (LDAPSocketOpenError, LDAPBindError) as e:
131
+ logger.warning(f"Connection failed to {server.host}:{server.port}: {e}")
132
+ last_error = e
133
+ continue
134
+
135
+ # If we get here, all servers failed for this attempt
136
+ if attempt < self.performance_config.max_retries - 1:
137
+ logger.info(f"Retry {attempt + 1}/{self.performance_config.max_retries} after {self.performance_config.retry_delay}s")
138
+ time.sleep(self.performance_config.retry_delay)
139
+
140
+ except Exception as e:
141
+ logger.error(f"Unexpected error during connection attempt {attempt + 1}: {e}")
142
+ last_error = e
143
+
144
+ if attempt < self.performance_config.max_retries - 1:
145
+ time.sleep(self.performance_config.retry_delay)
146
+
147
+ # All attempts failed
148
+ error_msg = f"Failed to connect to any LDAP server after {self.performance_config.max_retries} attempts"
149
+ if last_error:
150
+ error_msg += f". Last error: {last_error}"
151
+
152
+ logger.error(error_msg)
153
+ raise LDAPException(error_msg)
154
+
155
+ def disconnect(self) -> None:
156
+ """Disconnect from LDAP server."""
157
+ with self._lock:
158
+ if self._connection:
159
+ try:
160
+ self._connection.unbind()
161
+ logger.info("Disconnected from LDAP server")
162
+ except Exception as e:
163
+ logger.warning(f"Error during disconnect: {e}")
164
+ finally:
165
+ self._connection = None
166
+
167
+ def search(self,
168
+ search_base: str,
169
+ search_filter: str,
170
+ attributes: Union[List[str], str] = ALL_ATTRIBUTES,
171
+ search_scope: str = SUBTREE,
172
+ size_limit: int = 0) -> List[Dict[str, Any]]:
173
+ """
174
+ Perform LDAP search operation.
175
+
176
+ Args:
177
+ search_base: Base DN for search
178
+ search_filter: LDAP filter string
179
+ attributes: Attributes to retrieve
180
+ search_scope: Search scope (SUBTREE, ONELEVEL, BASE)
181
+ size_limit: Maximum number of results (0 = no limit)
182
+
183
+ Returns:
184
+ List of LDAP entries as dictionaries
185
+
186
+ Raises:
187
+ LDAPException: If search fails
188
+ """
189
+ connection = self.connect()
190
+
191
+ try:
192
+ logger.debug(f"Searching: base={search_base}, filter={search_filter}")
193
+
194
+ # Perform paged search for large result sets
195
+ paged_size = min(self.performance_config.page_size, size_limit) if size_limit > 0 else self.performance_config.page_size
196
+
197
+ entries = []
198
+ cookie = None
199
+
200
+ while True:
201
+ success = connection.search(
202
+ search_base=search_base,
203
+ search_filter=search_filter,
204
+ search_scope=search_scope,
205
+ attributes=attributes,
206
+ paged_size=paged_size,
207
+ paged_cookie=cookie
208
+ )
209
+
210
+ if not success:
211
+ logger.error(f"Search failed: {connection.result}")
212
+ raise LDAPException(f"Search failed: {connection.result}")
213
+
214
+ # Add entries to results
215
+ for entry in connection.entries:
216
+ entry_dict = {
217
+ 'dn': entry.entry_dn,
218
+ 'attributes': {}
219
+ }
220
+
221
+ for attr_name in entry.entry_attributes:
222
+ attr_value = getattr(entry, attr_name)
223
+ if hasattr(attr_value, 'value'):
224
+ entry_dict['attributes'][attr_name] = attr_value.value
225
+ else:
226
+ entry_dict['attributes'][attr_name] = str(attr_value)
227
+
228
+ entries.append(entry_dict)
229
+
230
+ # Check size limit
231
+ if size_limit > 0 and len(entries) >= size_limit:
232
+ logger.debug(f"Size limit reached: {size_limit}")
233
+ return entries[:size_limit]
234
+
235
+ # Check for more pages
236
+ cookie = connection.result.get('controls', {}).get('1.2.840.113556.1.4.319', {}).get('value', {}).get('cookie')
237
+ if not cookie:
238
+ break
239
+
240
+ logger.debug(f"Search returned {len(entries)} entries")
241
+ return entries
242
+
243
+ except Exception as e:
244
+ logger.error(f"Search error: {e}")
245
+ raise
246
+
247
+ def add(self, dn: str, attributes: Dict[str, Any]) -> bool:
248
+ """
249
+ Add LDAP entry.
250
+
251
+ Args:
252
+ dn: Distinguished name of new entry
253
+ attributes: Entry attributes
254
+
255
+ Returns:
256
+ True if successful
257
+
258
+ Raises:
259
+ LDAPException: If operation fails
260
+ """
261
+ connection = self.connect()
262
+
263
+ try:
264
+ logger.debug(f"Adding entry: {dn}")
265
+
266
+ success = connection.add(dn, attributes=attributes)
267
+
268
+ if success:
269
+ logger.info(f"Successfully added entry: {dn}")
270
+ return True
271
+ else:
272
+ logger.error(f"Failed to add entry {dn}: {connection.result}")
273
+ raise LDAPException(f"Add operation failed: {connection.result}")
274
+
275
+ except Exception as e:
276
+ logger.error(f"Add error for {dn}: {e}")
277
+ raise
278
+
279
+ def modify(self, dn: str, changes: Dict[str, Any]) -> bool:
280
+ """
281
+ Modify LDAP entry.
282
+
283
+ Args:
284
+ dn: Distinguished name of entry to modify
285
+ changes: Dictionary of changes to apply
286
+
287
+ Returns:
288
+ True if successful
289
+
290
+ Raises:
291
+ LDAPException: If operation fails
292
+ """
293
+ connection = self.connect()
294
+
295
+ try:
296
+ logger.debug(f"Modifying entry: {dn}")
297
+
298
+ success = connection.modify(dn, changes)
299
+
300
+ if success:
301
+ logger.info(f"Successfully modified entry: {dn}")
302
+ return True
303
+ else:
304
+ logger.error(f"Failed to modify entry {dn}: {connection.result}")
305
+ raise LDAPException(f"Modify operation failed: {connection.result}")
306
+
307
+ except Exception as e:
308
+ logger.error(f"Modify error for {dn}: {e}")
309
+ raise
310
+
311
+ def delete(self, dn: str) -> bool:
312
+ """
313
+ Delete LDAP entry.
314
+
315
+ Args:
316
+ dn: Distinguished name of entry to delete
317
+
318
+ Returns:
319
+ True if successful
320
+
321
+ Raises:
322
+ LDAPException: If operation fails
323
+ """
324
+ connection = self.connect()
325
+
326
+ try:
327
+ logger.debug(f"Deleting entry: {dn}")
328
+
329
+ success = connection.delete(dn)
330
+
331
+ if success:
332
+ logger.info(f"Successfully deleted entry: {dn}")
333
+ return True
334
+ else:
335
+ logger.error(f"Failed to delete entry {dn}: {connection.result}")
336
+ raise LDAPException(f"Delete operation failed: {connection.result}")
337
+
338
+ except Exception as e:
339
+ logger.error(f"Delete error for {dn}: {e}")
340
+ raise
341
+
342
+ def move(self, dn: str, new_parent: str) -> bool:
343
+ """
344
+ Move LDAP entry to new parent.
345
+
346
+ Args:
347
+ dn: Distinguished name of entry to move
348
+ new_parent: New parent DN
349
+
350
+ Returns:
351
+ True if successful
352
+
353
+ Raises:
354
+ LDAPException: If operation fails
355
+ """
356
+ connection = self.connect()
357
+
358
+ try:
359
+ logger.debug(f"Moving entry {dn} to {new_parent}")
360
+
361
+ success = connection.modify_dn(dn, new_superior=new_parent)
362
+
363
+ if success:
364
+ logger.info(f"Successfully moved entry {dn} to {new_parent}")
365
+ return True
366
+ else:
367
+ logger.error(f"Failed to move entry {dn}: {connection.result}")
368
+ raise LDAPException(f"Move operation failed: {connection.result}")
369
+
370
+ except Exception as e:
371
+ logger.error(f"Move error for {dn}: {e}")
372
+ raise
373
+
374
+ def test_connection(self) -> Dict[str, Any]:
375
+ """
376
+ Test LDAP connection and return server information.
377
+
378
+ Returns:
379
+ Dictionary with connection test results
380
+ """
381
+ try:
382
+ connection = self.connect()
383
+
384
+ # Get server info
385
+ server_info = {
386
+ 'connected': True,
387
+ 'server': connection.server.host,
388
+ 'port': connection.server.port,
389
+ 'ssl': connection.server.ssl,
390
+ 'bound': connection.bound,
391
+ 'user': connection.user
392
+ }
393
+
394
+ # Try a simple search to test functionality
395
+ try:
396
+ connection.search(
397
+ search_base=self.ad_config.base_dn,
398
+ search_filter='(objectClass=*)',
399
+ search_scope=ldap3.BASE,
400
+ attributes=['namingContexts']
401
+ )
402
+ server_info['search_test'] = True
403
+ except Exception as e:
404
+ server_info['search_test'] = False
405
+ server_info['search_error'] = str(e)
406
+
407
+ logger.info("Connection test successful")
408
+ return server_info
409
+
410
+ except Exception as e:
411
+ logger.error(f"Connection test failed: {e}")
412
+ return {
413
+ 'connected': False,
414
+ 'error': str(e)
415
+ }
416
+
417
+ def __enter__(self):
418
+ """Context manager entry."""
419
+ return self
420
+
421
+ def __exit__(self, exc_type, exc_val, exc_tb):
422
+ """Context manager exit."""
423
+ self.disconnect()