├── .python-version ├── config └── .env.example ├── src └── msgraph_mcp_server │ ├── auth │ ├── __init__.py │ └── graph_auth.py │ ├── tools │ └── __init__.py │ ├── utils │ ├── __init__.py │ ├── graph_client.py │ └── password_generator.py │ ├── __init__.py │ ├── resources │ ├── __init__.py │ ├── password_auth.py │ ├── managed_devices.py │ ├── audit_logs.py │ ├── mfa.py │ ├── signin_logs.py │ ├── service_principals.py │ ├── applications.py │ ├── users.py │ ├── permissions_helper.py │ ├── conditional_access.py │ └── groups.py │ └── server.py ├── pyproject.toml ├── .gitignore └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /config/.env.example: -------------------------------------------------------------------------------- 1 | TENANT_ID="" 2 | CLIENT_ID="" 3 | CLIENT_SECRET="" -------------------------------------------------------------------------------- /src/msgraph_mcp_server/auth/__init__.py: -------------------------------------------------------------------------------- 1 | """Authentication module for Microsoft Graph API.""" -------------------------------------------------------------------------------- /src/msgraph_mcp_server/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools for interacting with Microsoft Graph API.""" -------------------------------------------------------------------------------- /src/msgraph_mcp_server/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the Microsoft Graph MCP Server.""" -------------------------------------------------------------------------------- /src/msgraph_mcp_server/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Microsoft Graph MCP Server. 3 | 4 | A FastMCP server implementation that provides tools and resources 5 | for interacting with Microsoft Graph services. 6 | """ 7 | 8 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "entraid-mcp-server" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "azure-core>=1.33.0", 9 | "azure-identity>=1.21.0", 10 | "mcp[cli]>=1.6.0", 11 | "msgraph-core>=1.3.3", 12 | "msgraph-sdk>=1.28.0", 13 | ] 14 | -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """The resources package for Entra ID MCP Server. 2 | 3 | This package contains modules for interacting with Microsoft Graph resources. 4 | """ 5 | 6 | # Import modules to make them available through resources package 7 | from . import users, groups, signin_logs, mfa, conditional_access, managed_devices, audit_logs, password_auth, permissions_helper 8 | from . import applications, service_principals -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python bytecode and caches 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | build/ 7 | dist/ 8 | *.egg-info/ 9 | .eggs/ 10 | 11 | # Virtual environments 12 | .venv/ 13 | venv/ 14 | env/ 15 | 16 | # IDE / Editor settings 17 | .vscode/ 18 | .idea/ 19 | *.iml 20 | 21 | # MacOS 22 | .DS_Store 23 | 24 | # Logs and coverage 25 | *.log 26 | htmlcov/ 27 | .coverage 28 | .cache/ 29 | 30 | # Test & local databases 31 | *.sqlite 32 | *.sqlite3 33 | 34 | # Node 35 | node_modules/ 36 | 37 | # Secret / configuration files 38 | *.env 39 | config/.env 40 | *.pem 41 | *.key 42 | *.pfx 43 | 44 | # Git 45 | *.orig 46 | 47 | # Lock files (optional – uncomment if you don't commit lock files) 48 | # uv.lock 49 | # poetry.lock 50 | # Pipenv.lock 51 | permissions-reference.md 52 | -------------------------------------------------------------------------------- /src/msgraph_mcp_server/utils/graph_client.py: -------------------------------------------------------------------------------- 1 | """Microsoft Graph client utility. 2 | 3 | This module provides a utility class for making requests to the Microsoft Graph API. 4 | """ 5 | 6 | import logging 7 | from typing import Any, Dict, List, Optional 8 | 9 | from msgraph import GraphServiceClient 10 | 11 | from auth.graph_auth import GraphAuthManager 12 | 13 | class GraphClient: 14 | """Core client utility for Microsoft Graph API interactions. 15 | 16 | This class is responsible for: 17 | 1. Initializing and managing the Microsoft Graph client 18 | 2. Providing core API functionality 19 | 3. Handling shared request configurations 20 | """ 21 | 22 | def __init__(self, auth_manager: GraphAuthManager): 23 | """Initialize the GraphClient. 24 | 25 | Args: 26 | auth_manager: GraphAuthManager instance for authentication 27 | """ 28 | self.auth_manager = auth_manager 29 | self._client = None 30 | self.logger = logging.getLogger(__name__) 31 | 32 | def get_client(self) -> GraphServiceClient: 33 | """Get or create a Graph client. 34 | 35 | Returns: 36 | Initialized GraphServiceClient 37 | """ 38 | if self._client is None: 39 | self._client = self.auth_manager.get_graph_client() 40 | self.logger.info("Graph client initialized") 41 | return self._client 42 | 43 | async def execute_request(self, request_func, *args, **kwargs): 44 | """Execute a Graph API request with proper error handling. 45 | 46 | Args: 47 | request_func: The Graph API request function to call 48 | *args: Arguments to pass to the request function 49 | **kwargs: Keyword arguments to pass to the request function 50 | 51 | Returns: 52 | The response from the API 53 | 54 | Raises: 55 | Exception: If the request fails 56 | """ 57 | try: 58 | client = self.get_client() 59 | response = await request_func(*args, **kwargs) 60 | return response 61 | except Exception as e: 62 | self.logger.error(f"Error executing Graph API request: {str(e)}") 63 | if "Authorization_RequestDenied" in str(e): 64 | self.logger.error("Permission denied. Check application permissions.") 65 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/utils/password_generator.py: -------------------------------------------------------------------------------- 1 | import random 2 | import array 3 | 4 | def generate_secure_password(length: int = 12) -> str: 5 | """Generate a secure random password. 6 | 7 | The password will include at least one digit, uppercase letter, 8 | lowercase letter, and symbol, with the remaining characters 9 | randomly selected from all these categories. 10 | 11 | Args: 12 | length: The length of the password to generate (default: 12) 13 | 14 | Returns: 15 | A secure random password string 16 | """ 17 | # declare arrays of the character that we need in out password 18 | DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] 19 | LOCASE_CHARACTERS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 20 | 'i', 'j', 'k', 'm', 'n', 'o', 'p', 'q', 21 | 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 22 | 'z'] 23 | 24 | UPCASE_CHARACTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 25 | 'I', 'J', 'K', 'M', 'N', 'O', 'P', 'Q', 26 | 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 27 | 'Z'] 28 | 29 | SYMBOLS = ['@', '#', '$', '%', '=', ':', '?', '.', '/', '|', '~', '>', 30 | '*', '(', ')', '<'] 31 | 32 | # combines all the character arrays above to form one array 33 | COMBINED_LIST = DIGITS + UPCASE_CHARACTERS + LOCASE_CHARACTERS + SYMBOLS 34 | 35 | # randomly select at least one character from each character set above 36 | rand_digit = random.choice(DIGITS) 37 | rand_upper = random.choice(UPCASE_CHARACTERS) 38 | rand_lower = random.choice(LOCASE_CHARACTERS) 39 | rand_symbol = random.choice(SYMBOLS) 40 | 41 | # combine the character randomly selected above 42 | # at this stage, the password contains only 4 characters but 43 | # we want a password of the specified length 44 | temp_pass = rand_digit + rand_upper + rand_lower + rand_symbol 45 | 46 | # now that we are sure we have at least one character from each 47 | # set of characters, we fill the rest of 48 | # the password length by selecting randomly from the combined 49 | # list of character above. 50 | for x in range(length - 4): 51 | temp_pass = temp_pass + random.choice(COMBINED_LIST) 52 | 53 | # convert temporary password into array and shuffle to 54 | # prevent it from having a consistent pattern 55 | # where the beginning of the password is predictable 56 | temp_pass_list = array.array('u', temp_pass) 57 | random.shuffle(temp_pass_list) 58 | 59 | # traverse the temporary password array and append the chars 60 | # to form the password 61 | password = "" 62 | for x in temp_pass_list: 63 | password = password + x 64 | 65 | return password -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/password_auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List, Any, Optional 3 | from kiota_abstractions.base_request_configuration import RequestConfiguration 4 | from utils.graph_client import GraphClient 5 | from msgraph.generated.models.user import User 6 | from msgraph.generated.models.password_profile import PasswordProfile 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | async def list_user_password_methods(graph_client: GraphClient, user_id: str) -> List[Dict[str, Any]]: 12 | """List a user's password authentication methods. 13 | 14 | Args: 15 | graph_client: GraphClient instance 16 | user_id: The unique identifier of the user 17 | 18 | Returns: 19 | A list of password authentication methods 20 | """ 21 | try: 22 | client = graph_client.get_client() 23 | response = await client.users.by_user_id(user_id).authentication.password_methods.get() 24 | 25 | formatted_methods = [] 26 | if response and response.value: 27 | for method in response.value: 28 | method_data = { 29 | 'id': getattr(method, 'id', None), 30 | 'createdDateTime': method.created_date_time.isoformat() if getattr(method, 'created_date_time', None) else None 31 | } 32 | formatted_methods.append(method_data) 33 | 34 | return formatted_methods 35 | except Exception as e: 36 | logger.error(f"Error listing password methods for user {user_id}: {str(e)}") 37 | raise 38 | 39 | async def get_user_password_method(graph_client: GraphClient, user_id: str, method_id: str) -> Optional[Dict[str, Any]]: 40 | """Get a specific password authentication method for a user. 41 | 42 | Args: 43 | graph_client: GraphClient instance 44 | user_id: The unique identifier of the user 45 | method_id: The identifier of the password method 46 | 47 | Returns: 48 | A password authentication method or None if not found 49 | """ 50 | try: 51 | client = graph_client.get_client() 52 | method = await client.users.by_user_id(user_id).authentication.password_methods.by_password_authentication_method_id(method_id).get() 53 | 54 | if method: 55 | method_data = { 56 | 'id': getattr(method, 'id', None), 57 | 'createdDateTime': method.created_date_time.isoformat() if getattr(method, 'created_date_time', None) else None 58 | } 59 | return method_data 60 | return None 61 | except Exception as e: 62 | logger.error(f"Error getting password method {method_id} for user {user_id}: {str(e)}") 63 | raise 64 | 65 | 66 | async def reset_user_password_direct(graph_client: GraphClient, user_id: str, password: str, require_change_on_next_sign_in: bool = True) -> Dict[str, Any]: 67 | """Reset a user's password by directly updating the user object with a specific password. 68 | 69 | Args: 70 | graph_client: GraphClient instance 71 | user_id: The unique identifier of the user 72 | password: The new password to set for the user 73 | require_change_on_next_sign_in: Whether to require the user to change their password on next sign-in, default is True 74 | 75 | Returns: 76 | A dictionary with the operation result 77 | """ 78 | try: 79 | client = graph_client.get_client() 80 | 81 | # Create a password profile with the provided password 82 | password_profile = PasswordProfile( 83 | force_change_password_next_sign_in=require_change_on_next_sign_in, 84 | password=password 85 | ) 86 | 87 | # Create the request body 88 | request_body = User( 89 | password_profile=password_profile 90 | ) 91 | 92 | # Update the user 93 | await client.users.by_user_id(user_id).patch(request_body) 94 | 95 | # Return success result (Note: For security, we don't return the actual password) 96 | return { 97 | 'status': 'success', 98 | 'userId': user_id, 99 | 'passwordResetRequired': require_change_on_next_sign_in, 100 | 'message': 'Password has been reset using the direct method.' 101 | } 102 | except Exception as e: 103 | logger.error(f"Error directly resetting password for user {user_id}: {str(e)}") 104 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/managed_devices.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List, Any, Optional 3 | from msgraph.generated.device_management.managed_devices.managed_devices_request_builder import ManagedDevicesRequestBuilder 4 | from kiota_abstractions.base_request_configuration import RequestConfiguration 5 | from utils.graph_client import GraphClient 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | async def get_all_managed_devices(graph_client: GraphClient, filter_os: Optional[str] = None) -> List[Dict[str, Any]]: 10 | """Get all managed devices (optionally filter by OS), with paging support.""" 11 | try: 12 | client = graph_client.get_client() 13 | query_params = ManagedDevicesRequestBuilder.ManagedDevicesRequestBuilderGetQueryParameters() 14 | if filter_os: 15 | query_params.filter = f"operatingSystem eq '{filter_os}'" 16 | request_configuration = RequestConfiguration(query_parameters=query_params) 17 | request_configuration.headers.add("ConsistencyLevel", "eventual") 18 | response = await client.device_management.managed_devices.get(request_configuration=request_configuration) 19 | devices = [] 20 | if response and response.value: 21 | devices.extend(response.value) 22 | while response is not None and getattr(response, 'odata_next_link', None): 23 | response = await client.device_management.managed_devices.with_url(response.odata_next_link).get(request_configuration=request_configuration) 24 | if response and response.value: 25 | devices.extend(response.value) 26 | formatted_devices = [] 27 | for device in devices: 28 | device_data = { 29 | 'id': getattr(device, 'id', None), 30 | 'deviceName': getattr(device, 'device_name', None), 31 | 'userId': getattr(device, 'user_id', None), 32 | 'userPrincipalName': getattr(device, 'user_principal_name', None), 33 | 'operatingSystem': getattr(device, 'operating_system', None), 34 | 'osVersion': getattr(device, 'os_version', None), 35 | 'managementAgent': getattr(device, 'management_agent', None).value if getattr(device, 'management_agent', None) else None, 36 | 'complianceState': getattr(device, 'compliance_state', None).value if getattr(device, 'compliance_state', None) else None, 37 | 'jailBroken': getattr(device, 'jail_broken', None), 38 | 'enrollmentType': getattr(device, 'enrollment_type', None).value if getattr(device, 'enrollment_type', None) else None, 39 | 'lastSyncDateTime': getattr(device, 'last_sync_date_time', None).isoformat() if getattr(device, 'last_sync_date_time', None) else None 40 | } 41 | formatted_devices.append(device_data) 42 | return formatted_devices 43 | except Exception as e: 44 | logger.error(f"Error fetching all managed devices: {str(e)}") 45 | raise 46 | 47 | async def get_managed_devices_by_user(graph_client: GraphClient, user_id: str) -> List[Dict[str, Any]]: 48 | """Get all managed devices for a specific userId, with paging support.""" 49 | try: 50 | client = graph_client.get_client() 51 | query_params = ManagedDevicesRequestBuilder.ManagedDevicesRequestBuilderGetQueryParameters( 52 | filter=f"userId eq '{user_id}'" 53 | ) 54 | request_configuration = RequestConfiguration(query_parameters=query_params) 55 | request_configuration.headers.add("ConsistencyLevel", "eventual") 56 | response = await client.device_management.managed_devices.get(request_configuration=request_configuration) 57 | devices = [] 58 | if response and response.value: 59 | devices.extend(response.value) 60 | while response is not None and getattr(response, 'odata_next_link', None): 61 | response = await client.device_management.managed_devices.with_url(response.odata_next_link).get(request_configuration=request_configuration) 62 | if response and response.value: 63 | devices.extend(response.value) 64 | formatted_devices = [] 65 | for device in devices: 66 | device_data = { 67 | 'id': getattr(device, 'id', None), 68 | 'deviceName': getattr(device, 'device_name', None), 69 | 'userId': getattr(device, 'user_id', None), 70 | 'userPrincipalName': getattr(device, 'user_principal_name', None), 71 | 'operatingSystem': getattr(device, 'operating_system', None), 72 | 'osVersion': getattr(device, 'os_version', None), 73 | 'managementAgent': getattr(device, 'management_agent', None).value if getattr(device, 'management_agent', None) else None, 74 | 'complianceState': getattr(device, 'compliance_state', None).value if getattr(device, 'compliance_state', None) else None, 75 | 'jailBroken': getattr(device, 'jail_broken', None), 76 | 'enrollmentType': getattr(device, 'enrollment_type', None).value if getattr(device, 'enrollment_type', None) else None, 77 | 'lastSyncDateTime': getattr(device, 'last_sync_date_time', None).isoformat() if getattr(device, 'last_sync_date_time', None) else None 78 | } 79 | formatted_devices.append(device_data) 80 | return formatted_devices 81 | except Exception as e: 82 | logger.error(f"Error fetching managed devices for user {user_id}: {str(e)}") 83 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/audit_logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List, Any 3 | from datetime import datetime, timedelta, timezone 4 | from msgraph.generated.audit_logs.directory_audits.directory_audits_request_builder import DirectoryAuditsRequestBuilder 5 | from kiota_abstractions.base_request_configuration import RequestConfiguration 6 | from utils.graph_client import GraphClient 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | async def get_user_audit_logs(graph_client: GraphClient, user_id: str, days: int = 30) -> List[Dict[str, Any]]: 11 | """Get all relevant directory audit logs for a user by user_id within the last N days (default 30), with paging support.""" 12 | try: 13 | client = graph_client.get_client() 14 | end_date = datetime.now(timezone.utc) 15 | start_date = end_date - timedelta(days=days) 16 | start_date_str = start_date.strftime('%Y-%m-%dT%H:%M:%SZ') 17 | end_date_str = end_date.strftime('%Y-%m-%dT%H:%M:%SZ') 18 | # Filter: initiatedBy/user/id eq '{user_id}' and activityDateTime in range 19 | filter_query = f"initiatedBy/user/id eq '{user_id}' and activityDateTime ge {start_date_str} and activityDateTime le {end_date_str}" 20 | logger.info(f"Fetching directory audit logs for user ID: {user_id}") 21 | logger.info(f"Date range: {start_date_str} to {end_date_str}") 22 | logger.info(f"Filter query: {filter_query}") 23 | query_params = DirectoryAuditsRequestBuilder.DirectoryAuditsRequestBuilderGetQueryParameters( 24 | filter=filter_query, 25 | orderby=["activityDateTime desc"], 26 | top=1000 27 | ) 28 | request_configuration = RequestConfiguration(query_parameters=query_params) 29 | request_configuration.headers.add("ConsistencyLevel", "eventual") 30 | response = await client.audit_logs.directory_audits.get(request_configuration=request_configuration) 31 | logs = [] 32 | if response and response.value: 33 | logs.extend(response.value) 34 | while response is not None and getattr(response, 'odata_next_link', None): 35 | response = await client.audit_logs.directory_audits.with_url(response.odata_next_link).get(request_configuration=request_configuration) 36 | if response and response.value: 37 | logs.extend(response.value) 38 | formatted_logs = [] 39 | for log in logs: 40 | log_data = { 41 | "id": getattr(log, "id", None), 42 | "activityDateTime": log.activity_date_time.isoformat() if getattr(log, "activity_date_time", None) else None, 43 | "activityDisplayName": getattr(log, "activity_display_name", None), 44 | "category": getattr(log, "category", None), 45 | "operationType": getattr(log, "operation_type", None), 46 | "result": getattr(log, "result", None), 47 | "resultReason": getattr(log, "result_reason", None), 48 | "initiatedBy": None, 49 | "targetResources": None, 50 | "loggedByService": getattr(log, "logged_by_service", None), 51 | "correlationId": getattr(log, "correlation_id", None), 52 | "additionalDetails": [ 53 | {"key": getattr(kv, 'key', None), "value": getattr(kv, 'value', None)} for kv in getattr(log, 'additional_details', []) 54 | ] if hasattr(log, 'additional_details') and log.additional_details else [], 55 | } 56 | # initiatedBy 57 | if hasattr(log, 'initiated_by') and log.initiated_by: 58 | ib = log.initiated_by 59 | log_data["initiatedBy"] = { 60 | "user": { 61 | "id": getattr(ib.user, 'id', None) if hasattr(ib, 'user') and ib.user else None, 62 | "displayName": getattr(ib.user, 'display_name', None) if hasattr(ib, 'user') and ib.user else None, 63 | "userPrincipalName": getattr(ib.user, 'user_principal_name', None) if hasattr(ib, 'user') and ib.user else None 64 | } if hasattr(ib, 'user') and ib.user else None, 65 | "app": { 66 | "appId": getattr(ib.app, 'app_id', None) if hasattr(ib, 'app') and ib.app else None, 67 | "displayName": getattr(ib.app, 'display_name', None) if hasattr(ib, 'app') and ib.app else None 68 | } if hasattr(ib, 'app') and ib.app else None 69 | } 70 | # targetResources 71 | if hasattr(log, 'target_resources') and log.target_resources: 72 | log_data["targetResources"] = [ 73 | { 74 | "id": getattr(tr, 'id', None), 75 | "displayName": getattr(tr, 'display_name', None), 76 | "type": getattr(tr, 'type', None), 77 | "userPrincipalName": getattr(tr, 'user_principal_name', None), 78 | "modifiedProperties": [ 79 | { 80 | "displayName": getattr(mp, 'display_name', None), 81 | "oldValue": getattr(mp, 'old_value', None), 82 | "newValue": getattr(mp, 'new_value', None) 83 | } for mp in getattr(tr, 'modified_properties', []) 84 | ] if hasattr(tr, 'modified_properties') and tr.modified_properties else [] 85 | } 86 | for tr in log.target_resources 87 | ] 88 | formatted_logs.append(log_data) 89 | return formatted_logs 90 | except Exception as e: 91 | logger.error(f"Error fetching directory audit logs for user {user_id}: {str(e)}") 92 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/mfa.py: -------------------------------------------------------------------------------- 1 | """MFA resource module for Microsoft Graph. 2 | 3 | This module provides access to Microsoft Graph MFA-related resources. 4 | """ 5 | 6 | import logging 7 | from typing import Dict, List, Optional, Any 8 | 9 | from msgraph.generated.users.item.user_item_request_builder import UserItemRequestBuilder 10 | from kiota_abstractions.base_request_configuration import RequestConfiguration 11 | 12 | from utils.graph_client import GraphClient 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | async def get_mfa_status(graph_client: GraphClient, user_id: str) -> Dict[str, Any]: 17 | """Get MFA status and methods for a specific user. 18 | 19 | Args: 20 | graph_client: GraphClient instance 21 | user_id: The unique identifier of the user. 22 | 23 | Returns: 24 | A dictionary containing MFA status and methods information. 25 | """ 26 | try: 27 | client = graph_client.get_client() 28 | 29 | # Get user's company name 30 | query_params = UserItemRequestBuilder.UserItemRequestBuilderGetQueryParameters( 31 | select=["companyName"] 32 | ) 33 | request_configuration = RequestConfiguration( 34 | query_parameters=query_params 35 | ) 36 | user = await client.users.by_user_id(user_id).get(request_configuration=request_configuration) 37 | 38 | # Get MFA methods 39 | mfa_data = await client.users.by_user_id(user_id).authentication.methods.get() 40 | 41 | if not mfa_data: 42 | logger.warning(f"No MFA data found for user {user_id}") 43 | return None 44 | 45 | # Initialize MFA status object 46 | mfa_status = { 47 | 'userPrincipalName': user_id, 48 | 'mail': user.mail if user else None, 49 | 'companyName': user.company_name if user else None, 50 | 'mfaStatus': 'Disabled', 51 | 'methods': { 52 | 'email': False, 53 | 'fido2': False, 54 | 'authenticatorApp': False, 55 | 'password': False, 56 | 'phone': False, 57 | 'softwareOath': False, 58 | 'temporaryAccessPass': False, 59 | 'windowsHelloForBusiness': False 60 | } 61 | } 62 | 63 | # Process each authentication method 64 | for method in mfa_data.value: 65 | method_type = method.odata_type 66 | 67 | if method_type == "#microsoft.graph.emailAuthenticationMethod": 68 | mfa_status['methods']['email'] = True 69 | mfa_status['mfaStatus'] = "Enabled" 70 | elif method_type == "#microsoft.graph.fido2AuthenticationMethod": 71 | mfa_status['methods']['fido2'] = True 72 | mfa_status['mfaStatus'] = "Enabled" 73 | elif method_type == "#microsoft.graph.microsoftAuthenticatorAuthenticationMethod": 74 | mfa_status['methods']['authenticatorApp'] = True 75 | mfa_status['mfaStatus'] = "Enabled" 76 | elif method_type == "#microsoft.graph.passwordAuthenticationMethod": 77 | mfa_status['methods']['password'] = True 78 | if mfa_status['mfaStatus'] != "Enabled": 79 | mfa_status['mfaStatus'] = "Disabled" 80 | elif method_type == "#microsoft.graph.phoneAuthenticationMethod": 81 | mfa_status['methods']['phone'] = True 82 | mfa_status['mfaStatus'] = "Enabled" 83 | elif method_type == "#microsoft.graph.softwareOathAuthenticationMethod": 84 | mfa_status['methods']['softwareOath'] = True 85 | mfa_status['mfaStatus'] = "Enabled" 86 | elif method_type == "#microsoft.graph.temporaryAccessPassAuthenticationMethod": 87 | mfa_status['methods']['temporaryAccessPass'] = True 88 | mfa_status['mfaStatus'] = "Enabled" 89 | elif method_type == "#microsoft.graph.windowsHelloForBusinessAuthenticationMethod": 90 | mfa_status['methods']['windowsHelloForBusiness'] = True 91 | mfa_status['mfaStatus'] = "Enabled" 92 | 93 | return mfa_status 94 | 95 | except Exception as e: 96 | logger.error(f"Error fetching MFA status for user {user_id}: {str(e)}") 97 | raise 98 | 99 | async def get_group_mfa_status(graph_client: GraphClient, group_id: str) -> List[Dict[str, Any]]: 100 | """Get MFA status for all members of a group. 101 | 102 | Args: 103 | graph_client: GraphClient instance 104 | group_id: The unique identifier of the group. 105 | 106 | Returns: 107 | A list of dictionaries containing MFA status for each group member. 108 | """ 109 | try: 110 | client = graph_client.get_client() 111 | 112 | # Get group members 113 | members = await client.groups.by_group_id(group_id).members.get() 114 | if not members or not members.value: 115 | logger.warning(f"No members found in group {group_id}") 116 | return [] 117 | 118 | # Process each member's MFA status 119 | mfa_statuses = [] 120 | for member in members.value: 121 | try: 122 | mfa_status = await get_mfa_status(graph_client, member.id) 123 | if mfa_status: 124 | mfa_statuses.append(mfa_status) 125 | except Exception as e: 126 | logger.error(f"Error processing member {member.id}: {str(e)}") 127 | continue 128 | 129 | return mfa_statuses 130 | 131 | except Exception as e: 132 | logger.error(f"Error fetching group MFA status for group {group_id}: {str(e)}") 133 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/signin_logs.py: -------------------------------------------------------------------------------- 1 | """Sign-in logs resource module for Microsoft Graph. 2 | 3 | This module provides access to Microsoft Graph sign-in logs. 4 | """ 5 | 6 | import logging 7 | from typing import Dict, List, Optional, Any 8 | from datetime import datetime, timedelta, timezone 9 | 10 | from msgraph.generated.audit_logs.sign_ins.sign_ins_request_builder import SignInsRequestBuilder 11 | from kiota_abstractions.base_request_configuration import RequestConfiguration 12 | 13 | from utils.graph_client import GraphClient 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | async def get_user_sign_in_logs(graph_client: GraphClient, user_id: str, days: int = 7) -> List[Dict[str, Any]]: 18 | """Get sign-in logs for a specific user within the last N days. 19 | 20 | Args: 21 | graph_client: GraphClient instance 22 | user_id: The unique identifier of the user. 23 | days: The number of past days to retrieve logs for (default: 7). 24 | 25 | Returns: 26 | A list of dictionaries, each representing a sign-in log event. 27 | """ 28 | try: 29 | client = graph_client.get_client() 30 | 31 | # Calculate date range 32 | end_date = datetime.now(timezone.utc) 33 | start_date = end_date - timedelta(days=days) 34 | 35 | # Format dates for query using the exact format from documentation 36 | start_date_str = start_date.strftime('%Y-%m-%dT%H:%M:%SZ') 37 | end_date_str = end_date.strftime('%Y-%m-%dT%H:%M:%SZ') 38 | 39 | # Define the OData filter query with proper formatting 40 | filter_query = f"createdDateTime ge {start_date_str} and createdDateTime le {end_date_str} and userId eq '{user_id}'" 41 | 42 | logger.info(f"Fetching sign-in logs for user ID: {user_id}") 43 | logger.info(f"Date range: {start_date_str} to {end_date_str}") 44 | logger.info(f"Filter query: {filter_query}") 45 | 46 | # Set up query parameters using SignInsRequestBuilder 47 | query_params = SignInsRequestBuilder.SignInsRequestBuilderGetQueryParameters( 48 | filter=filter_query, 49 | orderby=['createdDateTime desc'], 50 | top=1000 # Increased from default to get more logs 51 | ) 52 | 53 | # Create request configuration 54 | request_configuration = RequestConfiguration( 55 | query_parameters=query_params 56 | ) 57 | request_configuration.headers.add("ConsistencyLevel", "eventual") 58 | 59 | # Execute the request 60 | sign_ins = await client.audit_logs.sign_ins.get(request_configuration=request_configuration) 61 | 62 | formatted_logs = [] 63 | if sign_ins and sign_ins.value: 64 | logger.info(f"Found {len(sign_ins.value)} sign-in records") 65 | 66 | for log in sign_ins.value: 67 | # Format each log entry with comprehensive fields 68 | log_data = { 69 | "id": log.id, 70 | "createdDateTime": log.created_date_time.isoformat() if log.created_date_time else None, 71 | "userId": log.user_id, 72 | "userDisplayName": log.user_display_name, 73 | "userPrincipalName": log.user_principal_name, 74 | "appDisplayName": log.app_display_name, 75 | "appId": log.app_id, 76 | "ipAddress": log.ip_address, 77 | "clientAppUsed": log.client_app_used, 78 | "correlationId": log.correlation_id, 79 | "isInteractive": log.is_interactive, 80 | "resourceDisplayName": log.resource_display_name, 81 | "status": { 82 | "errorCode": log.status.error_code if log.status else None, 83 | "failureReason": log.status.failure_reason if log.status else None, 84 | "additionalDetails": log.status.additional_details if log.status else None 85 | }, 86 | "riskInformation": { 87 | "riskDetail": log.risk_detail, 88 | "riskLevelAggregated": log.risk_level_aggregated, 89 | "riskLevelDuringSignIn": log.risk_level_during_sign_in, 90 | "riskState": log.risk_state, 91 | "riskEventTypes": log.risk_event_types_v2 if hasattr(log, 'risk_event_types_v2') else [] 92 | } 93 | } 94 | 95 | # Add device details if available 96 | if hasattr(log, 'device_detail') and log.device_detail: 97 | device = log.device_detail 98 | log_data["deviceDetail"] = { 99 | "deviceId": device.device_id, 100 | "displayName": device.display_name, 101 | "operatingSystem": device.operating_system, 102 | "browser": device.browser, 103 | "isCompliant": device.is_compliant, 104 | "isManaged": device.is_managed, 105 | "trustType": device.trust_type 106 | } 107 | 108 | # Add location if available 109 | if hasattr(log, 'location') and log.location: 110 | location = log.location 111 | log_data["location"] = { 112 | "city": location.city, 113 | "state": location.state, 114 | "countryOrRegion": location.country_or_region, 115 | "coordinates": None 116 | } 117 | 118 | # Add coordinates if available 119 | if hasattr(location, 'geo_coordinates') and location.geo_coordinates: 120 | log_data["location"]["coordinates"] = { 121 | "latitude": location.geo_coordinates.latitude, 122 | "longitude": location.geo_coordinates.longitude 123 | } 124 | 125 | formatted_logs.append(log_data) 126 | else: 127 | logger.info(f"No sign-in logs found for user {user_id} in the last {days} days.") 128 | 129 | return formatted_logs 130 | 131 | except Exception as e: 132 | logger.error(f"Error fetching sign-in logs for user {user_id}: {str(e)}") 133 | # Check for permission errors specifically 134 | if "Authorization_RequestDenied" in str(e): 135 | logger.error("Permission denied. Ensure the application has AuditLog.Read.All permission.") 136 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/service_principals.py: -------------------------------------------------------------------------------- 1 | """Service Principals resource module for Microsoft Graph. 2 | 3 | This module provides access to Microsoft Graph service principal resources. 4 | """ 5 | 6 | import logging 7 | from typing import Dict, List, Any, Optional 8 | from utils.graph_client import GraphClient 9 | from msgraph.generated.models.service_principal import ServicePrincipal 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | async def list_service_principals(graph_client: GraphClient, limit: int = 100) -> List[Dict[str, Any]]: 14 | """List all service principals in the tenant, with paging.""" 15 | try: 16 | client = graph_client.get_client() 17 | response = await client.service_principals.get() 18 | service_principals = [] 19 | if response and response.value: 20 | service_principals.extend(response.value) 21 | # Paging: fetch more if odata_next_link is present 22 | while response is not None and getattr(response, 'odata_next_link', None) and len(service_principals) < limit: 23 | response = await client.service_principals.with_url(response.odata_next_link).get() 24 | if response and response.value: 25 | service_principals.extend(response.value) 26 | formatted_sps = [] 27 | for sp in service_principals[:limit]: 28 | sp_data = { 29 | 'id': getattr(sp, 'id', None), 30 | 'appId': getattr(sp, 'app_id', None), 31 | 'displayName': getattr(sp, 'display_name', None), 32 | 'createdDateTime': sp.created_date_time.isoformat() if getattr(sp, 'created_date_time', None) else None, 33 | 'accountEnabled': getattr(sp, 'account_enabled', None), 34 | 'appOwnerOrganizationId': getattr(sp, 'app_owner_organization_id', None), 35 | 'tags': getattr(sp, 'tags', None), 36 | } 37 | formatted_sps.append(sp_data) 38 | return formatted_sps 39 | except Exception as e: 40 | logger.error(f"Error listing service principals: {str(e)}") 41 | raise 42 | 43 | async def get_service_principal_by_app_id(graph_client: GraphClient, app_id: str) -> Optional[Any]: 44 | """Get a service principal by its appId (application client ID).""" 45 | try: 46 | client = graph_client.get_client() 47 | # Filter by appId 48 | filter_query = f"appId eq '{app_id}'" 49 | response = await client.service_principals.get(query_parameters={"$filter": filter_query}) 50 | if response and response.value: 51 | return response.value[0] # Return the first match 52 | return None 53 | except Exception as e: 54 | logger.error(f"Error getting service principal by appId {app_id}: {str(e)}") 55 | raise 56 | 57 | async def get_service_principal_by_id(graph_client: GraphClient, sp_id: str) -> Optional[Dict[str, Any]]: 58 | """Get a specific service principal by its object ID, including appRoleAssignments and oauth2PermissionGrants.""" 59 | try: 60 | client = graph_client.get_client() 61 | sp = await client.service_principals.by_service_principal_id(sp_id).get() 62 | if sp: 63 | sp_data = { 64 | 'id': getattr(sp, 'id', None), 65 | 'appId': getattr(sp, 'app_id', None), 66 | 'displayName': getattr(sp, 'display_name', None), 67 | 'createdDateTime': sp.created_date_time.isoformat() if getattr(sp, 'created_date_time', None) else None, 68 | 'accountEnabled': getattr(sp, 'account_enabled', None), 69 | 'appOwnerOrganizationId': getattr(sp, 'app_owner_organization_id', None), 70 | 'tags': getattr(sp, 'tags', None), 71 | } 72 | # Fetch appRoleAssignments (application permissions) 73 | app_role_assignments = [] 74 | try: 75 | response = await client.service_principals.by_service_principal_id(sp_id).app_role_assignments.get() 76 | while response: 77 | if response.value: 78 | for assignment in response.value: 79 | app_role_assignments.append({ 80 | 'id': getattr(assignment, 'id', None), 81 | 'createdDateTime': getattr(assignment, 'created_date_time', None), 82 | 'appRoleId': getattr(assignment, 'app_role_id', None), 83 | 'principalDisplayName': getattr(assignment, 'principal_display_name', None), 84 | 'principalId': getattr(assignment, 'principal_id', None), 85 | 'principalType': getattr(assignment, 'principal_type', None), 86 | 'resourceDisplayName': getattr(assignment, 'resource_display_name', None), 87 | 'resourceId': getattr(assignment, 'resource_id', None), 88 | }) 89 | if getattr(response, 'odata_next_link', None): 90 | response = await client.service_principals.by_service_principal_id(sp_id).app_role_assignments.with_url(response.odata_next_link).get() 91 | else: 92 | break 93 | except Exception as e: 94 | logger.warning(f"Error fetching appRoleAssignments for service principal {sp_id}: {str(e)}") 95 | sp_data['appRoleAssignments'] = app_role_assignments 96 | 97 | # Fetch oauth2PermissionGrants (delegated permissions) 98 | oauth2_permission_grants = [] 99 | try: 100 | response = await client.service_principals.by_service_principal_id(sp_id).oauth2_permission_grants.get() 101 | while response: 102 | if response.value: 103 | for grant in response.value: 104 | oauth2_permission_grants.append({ 105 | 'id': getattr(grant, 'id', None), 106 | 'clientId': getattr(grant, 'client_id', None), 107 | 'consentType': getattr(grant, 'consent_type', None), 108 | 'principalId': getattr(grant, 'principal_id', None), 109 | 'resourceId': getattr(grant, 'resource_id', None), 110 | 'scope': getattr(grant, 'scope', None), 111 | }) 112 | if getattr(response, 'odata_next_link', None): 113 | response = await client.service_principals.by_service_principal_id(sp_id).oauth2_permission_grants.with_url(response.odata_next_link).get() 114 | else: 115 | break 116 | except Exception as e: 117 | logger.warning(f"Error fetching oauth2PermissionGrants for service principal {sp_id}: {str(e)}") 118 | sp_data['oauth2PermissionGrants'] = oauth2_permission_grants 119 | 120 | return sp_data 121 | return None 122 | except Exception as e: 123 | logger.error(f"Error getting service principal {sp_id}: {str(e)}") 124 | raise 125 | 126 | async def create_service_principal(graph_client: GraphClient, sp_data: Dict[str, Any]) -> Dict[str, Any]: 127 | """Create a new service principal.""" 128 | try: 129 | client = graph_client.get_client() 130 | sp = ServicePrincipal() 131 | # Set properties from sp_data 132 | if 'appId' in sp_data: 133 | sp.app_id = sp_data['appId'] 134 | if 'accountEnabled' in sp_data: 135 | sp.account_enabled = sp_data['accountEnabled'] 136 | if 'tags' in sp_data: 137 | sp.tags = sp_data['tags'] 138 | if 'appRoleAssignmentRequired' in sp_data: 139 | sp.app_role_assignment_required = sp_data['appRoleAssignmentRequired'] 140 | if 'displayName' in sp_data: 141 | sp.display_name = sp_data['displayName'] 142 | new_sp = await client.service_principals.post(sp) 143 | if new_sp: 144 | return { 145 | 'id': getattr(new_sp, 'id', None), 146 | 'appId': getattr(new_sp, 'app_id', None), 147 | 'displayName': getattr(new_sp, 'display_name', None), 148 | 'createdDateTime': new_sp.created_date_time.isoformat() if getattr(new_sp, 'created_date_time', None) else None, 149 | 'accountEnabled': getattr(new_sp, 'account_enabled', None), 150 | 'appOwnerOrganizationId': getattr(new_sp, 'app_owner_organization_id', None), 151 | 'tags': getattr(new_sp, 'tags', None), 152 | } 153 | raise Exception("Failed to create service principal") 154 | except Exception as e: 155 | logger.error(f"Error creating service principal: {str(e)}") 156 | raise 157 | 158 | async def update_service_principal(graph_client: GraphClient, sp_id: str, sp_data: Dict[str, Any]) -> Dict[str, Any]: 159 | """Update an existing service principal.""" 160 | try: 161 | client = graph_client.get_client() 162 | sp = ServicePrincipal() 163 | # Set updatable properties from sp_data 164 | if 'accountEnabled' in sp_data: 165 | sp.account_enabled = sp_data['accountEnabled'] 166 | if 'tags' in sp_data: 167 | sp.tags = sp_data['tags'] 168 | if 'appRoleAssignmentRequired' in sp_data: 169 | sp.app_role_assignment_required = sp_data['appRoleAssignmentRequired'] 170 | if 'displayName' in sp_data: 171 | sp.display_name = sp_data['displayName'] 172 | await client.service_principals.by_service_principal_id(sp_id).patch(sp) 173 | # Return the updated service principal 174 | return await get_service_principal_by_id(graph_client, sp_id) 175 | except Exception as e: 176 | logger.error(f"Error updating service principal {sp_id}: {str(e)}") 177 | raise 178 | 179 | async def delete_service_principal(graph_client: GraphClient, sp_id: str) -> bool: 180 | """Delete a service principal by its object ID.""" 181 | try: 182 | client = graph_client.get_client() 183 | await client.service_principals.by_service_principal_id(sp_id).delete() 184 | return True 185 | except Exception as e: 186 | logger.error(f"Error deleting service principal {sp_id}: {str(e)}") 187 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/applications.py: -------------------------------------------------------------------------------- 1 | """Applications resource module for Microsoft Graph. 2 | 3 | This module provides access to Microsoft Graph application resources (app registrations). 4 | """ 5 | 6 | import logging 7 | from typing import Dict, List, Any, Optional 8 | from utils.graph_client import GraphClient 9 | from msgraph.generated.models.application import Application 10 | from .service_principals import get_service_principal_by_app_id 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | async def list_applications(graph_client: GraphClient, limit: int = 100) -> List[Dict[str, Any]]: 15 | """List all applications (app registrations) in the tenant, with paging.""" 16 | try: 17 | client = graph_client.get_client() 18 | response = await client.applications.get() 19 | applications = [] 20 | if response and response.value: 21 | applications.extend(response.value) 22 | # Paging: fetch more if odata_next_link is present 23 | while response is not None and getattr(response, 'odata_next_link', None) and len(applications) < limit: 24 | response = await client.applications.with_url(response.odata_next_link).get() 25 | if response and response.value: 26 | applications.extend(response.value) 27 | formatted_apps = [] 28 | for app in applications[:limit]: 29 | app_data = { 30 | 'id': getattr(app, 'id', None), 31 | 'appId': getattr(app, 'app_id', None), 32 | 'displayName': getattr(app, 'display_name', None), 33 | 'createdDateTime': app.created_date_time.isoformat() if getattr(app, 'created_date_time', None) else None, 34 | 'signInAudience': getattr(app, 'sign_in_audience', None), 35 | 'publisherDomain': getattr(app, 'publisher_domain', None), 36 | 'tags': getattr(app, 'tags', None), 37 | } 38 | formatted_apps.append(app_data) 39 | return formatted_apps 40 | except Exception as e: 41 | logger.error(f"Error listing applications: {str(e)}") 42 | raise 43 | 44 | async def get_application_by_id(graph_client: GraphClient, app_id: str) -> Optional[Dict[str, Any]]: 45 | """Get a specific application by its object ID, including appRoleAssignments and oauth2PermissionGrants from the corresponding service principal.""" 46 | try: 47 | client = graph_client.get_client() 48 | app = await client.applications.by_application_id(app_id).get() 49 | if app: 50 | app_data = { 51 | 'id': getattr(app, 'id', None), 52 | 'appId': getattr(app, 'app_id', None), 53 | 'displayName': getattr(app, 'display_name', None), 54 | 'createdDateTime': app.created_date_time.isoformat() if getattr(app, 'created_date_time', None) else None, 55 | 'signInAudience': getattr(app, 'sign_in_audience', None), 56 | 'publisherDomain': getattr(app, 'publisher_domain', None), 57 | 'tags': getattr(app, 'tags', None), 58 | } 59 | # Find the corresponding service principal by appId 60 | sp = await get_service_principal_by_app_id(graph_client, getattr(app, 'app_id', None)) 61 | if sp: 62 | sp_id = getattr(sp, 'id', None) 63 | # Fetch appRoleAssignments and oauth2PermissionGrants using the same logic as in service_principals.py 64 | # Fetch appRoleAssignments 65 | app_role_assignments = [] 66 | try: 67 | response = await client.service_principals.by_service_principal_id(sp_id).app_role_assignments.get() 68 | while response: 69 | if response.value: 70 | for assignment in response.value: 71 | app_role_assignments.append({ 72 | 'id': getattr(assignment, 'id', None), 73 | 'createdDateTime': getattr(assignment, 'created_date_time', None), 74 | 'appRoleId': getattr(assignment, 'app_role_id', None), 75 | 'principalDisplayName': getattr(assignment, 'principal_display_name', None), 76 | 'principalId': getattr(assignment, 'principal_id', None), 77 | 'principalType': getattr(assignment, 'principal_type', None), 78 | 'resourceDisplayName': getattr(assignment, 'resource_display_name', None), 79 | 'resourceId': getattr(assignment, 'resource_id', None), 80 | }) 81 | if getattr(response, 'odata_next_link', None): 82 | response = await client.service_principals.by_service_principal_id(sp_id).app_role_assignments.with_url(response.odata_next_link).get() 83 | else: 84 | break 85 | except Exception as e: 86 | logger.warning(f"Error fetching appRoleAssignments for service principal {sp_id}: {str(e)}") 87 | app_data['appRoleAssignments'] = app_role_assignments 88 | 89 | # Fetch oauth2PermissionGrants 90 | oauth2_permission_grants = [] 91 | try: 92 | response = await client.service_principals.by_service_principal_id(sp_id).oauth2_permission_grants.get() 93 | while response: 94 | if response.value: 95 | for grant in response.value: 96 | oauth2_permission_grants.append({ 97 | 'id': getattr(grant, 'id', None), 98 | 'clientId': getattr(grant, 'client_id', None), 99 | 'consentType': getattr(grant, 'consent_type', None), 100 | 'principalId': getattr(grant, 'principal_id', None), 101 | 'resourceId': getattr(grant, 'resource_id', None), 102 | 'scope': getattr(grant, 'scope', None), 103 | }) 104 | if getattr(response, 'odata_next_link', None): 105 | response = await client.service_principals.by_service_principal_id(sp_id).oauth2_permission_grants.with_url(response.odata_next_link).get() 106 | else: 107 | break 108 | except Exception as e: 109 | logger.warning(f"Error fetching oauth2PermissionGrants for service principal {sp_id}: {str(e)}") 110 | app_data['oauth2PermissionGrants'] = oauth2_permission_grants 111 | else: 112 | app_data['appRoleAssignments'] = [] 113 | app_data['oauth2PermissionGrants'] = [] 114 | return app_data 115 | return None 116 | except Exception as e: 117 | logger.error(f"Error getting application {app_id}: {str(e)}") 118 | raise 119 | 120 | async def create_application(graph_client: GraphClient, app_data: Dict[str, Any]) -> Dict[str, Any]: 121 | """Create a new application (app registration).""" 122 | try: 123 | client = graph_client.get_client() 124 | app = Application() 125 | # Set properties from app_data 126 | if 'displayName' in app_data: 127 | app.display_name = app_data['displayName'] 128 | if 'signInAudience' in app_data: 129 | app.sign_in_audience = app_data['signInAudience'] 130 | if 'tags' in app_data: 131 | app.tags = app_data['tags'] 132 | if 'identifierUris' in app_data: 133 | app.identifier_uris = app_data['identifierUris'] 134 | if 'web' in app_data: 135 | app.web = app_data['web'] 136 | if 'api' in app_data: 137 | app.api = app_data['api'] 138 | if 'requiredResourceAccess' in app_data: 139 | app.required_resource_access = app_data['requiredResourceAccess'] 140 | new_app = await client.applications.post(app) 141 | if new_app: 142 | return { 143 | 'id': getattr(new_app, 'id', None), 144 | 'appId': getattr(new_app, 'app_id', None), 145 | 'displayName': getattr(new_app, 'display_name', None), 146 | 'createdDateTime': new_app.created_date_time.isoformat() if getattr(new_app, 'created_date_time', None) else None, 147 | 'signInAudience': getattr(new_app, 'sign_in_audience', None), 148 | 'publisherDomain': getattr(new_app, 'publisher_domain', None), 149 | 'tags': getattr(new_app, 'tags', None), 150 | } 151 | raise Exception("Failed to create application") 152 | except Exception as e: 153 | logger.error(f"Error creating application: {str(e)}") 154 | raise 155 | 156 | async def update_application(graph_client: GraphClient, app_id: str, app_data: Dict[str, Any]) -> Dict[str, Any]: 157 | """Update an existing application (app registration).""" 158 | try: 159 | client = graph_client.get_client() 160 | app = Application() 161 | # Set updatable properties from app_data 162 | if 'displayName' in app_data: 163 | app.display_name = app_data['displayName'] 164 | if 'signInAudience' in app_data: 165 | app.sign_in_audience = app_data['signInAudience'] 166 | if 'tags' in app_data: 167 | app.tags = app_data['tags'] 168 | if 'identifierUris' in app_data: 169 | app.identifier_uris = app_data['identifierUris'] 170 | if 'web' in app_data: 171 | app.web = app_data['web'] 172 | if 'api' in app_data: 173 | app.api = app_data['api'] 174 | if 'requiredResourceAccess' in app_data: 175 | app.required_resource_access = app_data['requiredResourceAccess'] 176 | await client.applications.by_application_id(app_id).patch(app) 177 | # Return the updated application 178 | return await get_application_by_id(graph_client, app_id) 179 | except Exception as e: 180 | logger.error(f"Error updating application {app_id}: {str(e)}") 181 | raise 182 | 183 | async def delete_application(graph_client: GraphClient, app_id: str) -> bool: 184 | """Delete an application (app registration) by its object ID.""" 185 | try: 186 | client = graph_client.get_client() 187 | await client.applications.by_application_id(app_id).delete() 188 | return True 189 | except Exception as e: 190 | logger.error(f"Error deleting application {app_id}: {str(e)}") 191 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/users.py: -------------------------------------------------------------------------------- 1 | """User resource module for Microsoft Graph. 2 | 3 | This module provides access to Microsoft Graph user resources. 4 | """ 5 | 6 | import logging 7 | from typing import Dict, List, Optional, Any 8 | 9 | from msgraph.generated.users.users_request_builder import UsersRequestBuilder 10 | from kiota_abstractions.base_request_configuration import RequestConfiguration 11 | from msgraph.generated.directory_roles.directory_roles_request_builder import DirectoryRolesRequestBuilder 12 | from msgraph.generated.directory_roles.item.directory_role_item_request_builder import DirectoryRoleItemRequestBuilder 13 | from msgraph.generated.directory_roles.item.members.members_request_builder import MembersRequestBuilder 14 | 15 | from utils.graph_client import GraphClient 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | async def search_users(graph_client: GraphClient, query: str, limit: int = 10) -> List[Dict[str, str]]: 20 | """Search for users by name or email, with paging support.""" 21 | try: 22 | client = graph_client.get_client() 23 | # Create query parameters for the search 24 | query_params = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( 25 | search=[ 26 | f'(\"displayName:{query}\" OR \"mail:{query}\" OR \"userPrincipalName:{query}\" OR \"givenName:{query}\" OR \"surName:{query}\" OR \"otherMails:{query}\")' 27 | ], 28 | top=limit 29 | ) 30 | # Create request configuration 31 | request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( 32 | query_parameters=query_params 33 | ) 34 | request_configuration.headers.add("ConsistencyLevel", "eventual") 35 | # Execute the search 36 | response = await client.users.get(request_configuration=request_configuration) 37 | users = [] 38 | if response and response.value: 39 | users.extend(response.value) 40 | # Paging: fetch more if odata_next_link is present, but stop if we reach the limit 41 | while response is not None and getattr(response, 'odata_next_link', None) and len(users) < limit: 42 | response = await client.users.with_url(response.odata_next_link).get() 43 | if response and response.value: 44 | users.extend(response.value) 45 | # Format the response with all user fields 46 | formatted_users = [] 47 | for user in users[:limit]: 48 | user_data = { 49 | 'id': user.id, 50 | 'displayName': user.display_name, 51 | 'mail': user.mail, 52 | 'userPrincipalName': user.user_principal_name, 53 | 'givenName': user.given_name, 54 | 'surname': user.surname, 55 | 'jobTitle': user.job_title, 56 | 'officeLocation': user.office_location, 57 | 'businessPhones': user.business_phones, 58 | 'mobilePhone': user.mobile_phone 59 | } 60 | formatted_users.append(user_data) 61 | return formatted_users 62 | except Exception as e: 63 | logger.error(f"Error searching users: {str(e)}") 64 | raise 65 | 66 | async def get_user_by_id(graph_client: GraphClient, user_id: str) -> Optional[Dict[str, Any]]: 67 | """Get a user by their ID. 68 | 69 | Args: 70 | graph_client: GraphClient instance 71 | user_id: The unique identifier of the user. 72 | 73 | Returns: 74 | A dictionary containing the user's details if found, otherwise None. 75 | """ 76 | try: 77 | client = graph_client.get_client() 78 | ms_user = await client.users.by_user_id(user_id).get() 79 | 80 | if ms_user: 81 | # Convert MS Graph User to our dictionary format 82 | user_data = { 83 | 'id': ms_user.id, 84 | 'displayName': ms_user.display_name, 85 | 'mail': ms_user.mail, 86 | 'userPrincipalName': ms_user.user_principal_name, 87 | 'givenName': ms_user.given_name, 88 | 'surname': ms_user.surname, 89 | 'jobTitle': ms_user.job_title, 90 | 'officeLocation': ms_user.office_location, 91 | 'businessPhones': ms_user.business_phones, 92 | 'mobilePhone': ms_user.mobile_phone 93 | } 94 | return user_data 95 | else: 96 | logger.warning(f"User with ID {user_id} not found.") 97 | return None 98 | 99 | except Exception as e: 100 | logger.error(f"Error fetching user with ID {user_id}: {str(e)}") 101 | raise 102 | 103 | async def get_privileged_users(graph_client: GraphClient) -> List[Dict[str, Any]]: 104 | """Get all users who are members of privileged directory roles, with paging support for members.""" 105 | try: 106 | client = graph_client.get_client() 107 | # Get all activated directory roles 108 | roles_response = await client.directory_roles.get() 109 | privileged_users = {} 110 | if roles_response and roles_response.value: 111 | for role in roles_response.value: 112 | # For each role, get its members (with paging) 113 | role_id = role.id 114 | role_name = getattr(role, 'display_name', None) 115 | if not role_id: 116 | continue 117 | members_response = await client.directory_roles.by_directory_role_id(role_id).members.get() 118 | members = [] 119 | if members_response and members_response.value: 120 | members.extend(members_response.value) 121 | while members_response is not None and getattr(members_response, 'odata_next_link', None): 122 | members_response = await client.directory_roles.by_directory_role_id(role_id).members.with_url(members_response.odata_next_link).get() 123 | if members_response and members_response.value: 124 | members.extend(members_response.value) 125 | for member in members: 126 | # Only process user objects (type: #microsoft.graph.user) 127 | if hasattr(member, 'odata_type') and member.odata_type == '#microsoft.graph.user': 128 | user_id = getattr(member, 'id', None) 129 | if not user_id: 130 | continue 131 | # Deduplicate by user_id 132 | if user_id not in privileged_users: 133 | privileged_users[user_id] = { 134 | 'id': user_id, 135 | 'displayName': getattr(member, 'display_name', None), 136 | 'mail': getattr(member, 'mail', None), 137 | 'userPrincipalName': getattr(member, 'user_principal_name', None), 138 | 'givenName': getattr(member, 'given_name', None), 139 | 'surname': getattr(member, 'surname', None), 140 | 'jobTitle': getattr(member, 'job_title', None), 141 | 'officeLocation': getattr(member, 'office_location', None), 142 | 'businessPhones': getattr(member, 'business_phones', None), 143 | 'mobilePhone': getattr(member, 'mobile_phone', None), 144 | 'roles': set() 145 | } 146 | # Add the role name to the user's roles set 147 | privileged_users[user_id]['roles'].add(role_name) 148 | # Convert roles set to list for each user 149 | for user in privileged_users.values(): 150 | user['roles'] = list(user['roles']) 151 | return list(privileged_users.values()) 152 | except Exception as e: 153 | logger.error(f"Error fetching privileged users: {str(e)}") 154 | raise 155 | 156 | async def get_user_groups(graph_client: GraphClient, user_id: str) -> List[Dict[str, Any]]: 157 | """Get all groups (including transitive memberships) for a user by user ID, with paging support.""" 158 | try: 159 | client = graph_client.get_client() 160 | memberships_response = await client.users.by_user_id(user_id).transitive_member_of.get() 161 | memberships = [] 162 | if memberships_response and memberships_response.value: 163 | memberships.extend(memberships_response.value) 164 | # Paging for memberships 165 | while memberships_response is not None and getattr(memberships_response, 'odata_next_link', None): 166 | memberships_response = await client.users.by_user_id(user_id).transitive_member_of.with_url(memberships_response.odata_next_link).get() 167 | if memberships_response and memberships_response.value: 168 | memberships.extend(memberships_response.value) 169 | # For each membership, fetch group details if it is a group 170 | groups_list = [] 171 | for membership in memberships: 172 | if hasattr(membership, 'odata_type') and membership.odata_type == '#microsoft.graph.group': 173 | group_id = getattr(membership, 'id', None) 174 | if not group_id: 175 | continue 176 | group = await client.groups.by_group_id(group_id).get() 177 | if group: 178 | group_data = { 179 | 'id': group.id, 180 | 'displayName': getattr(group, 'display_name', None), 181 | 'mail': getattr(group, 'mail', None), 182 | 'groupTypes': getattr(group, 'group_types', None), 183 | 'description': getattr(group, 'description', None) 184 | } 185 | groups_list.append(group_data) 186 | return groups_list 187 | except Exception as e: 188 | logger.error(f"Error fetching groups for user {user_id}: {str(e)}") 189 | raise 190 | 191 | async def get_user_roles(graph_client: GraphClient, user_id: str) -> List[Dict[str, Any]]: 192 | """Get all directory roles assigned to a user by user ID, with paging support.""" 193 | try: 194 | client = graph_client.get_client() 195 | memberof_response = await client.users.by_user_id(user_id).member_of.get() 196 | memberships = [] 197 | if memberof_response and memberof_response.value: 198 | memberships.extend(memberof_response.value) 199 | # Paging for memberOf 200 | while memberof_response is not None and getattr(memberof_response, 'odata_next_link', None): 201 | memberof_response = await client.users.by_user_id(user_id).member_of.with_url(memberof_response.odata_next_link).get() 202 | if memberof_response and memberof_response.value: 203 | memberships.extend(memberof_response.value) 204 | # For each membership, filter for directoryRole objects 205 | roles_list = [] 206 | for membership in memberships: 207 | if hasattr(membership, 'odata_type') and membership.odata_type == '#microsoft.graph.directoryRole': 208 | role_id = getattr(membership, 'id', None) 209 | if not role_id: 210 | continue 211 | # Optionally fetch more details if needed 212 | role = await client.directory_roles.by_directory_role_id(role_id).get() 213 | if role: 214 | role_data = { 215 | 'id': role.id, 216 | 'displayName': getattr(role, 'display_name', None), 217 | 'description': getattr(role, 'description', None), 218 | 'roleTemplateId': getattr(role, 'role_template_id', None) 219 | } 220 | roles_list.append(role_data) 221 | return roles_list 222 | except Exception as e: 223 | logger.error(f"Error fetching roles for user {user_id}: {str(e)}") 224 | raise 225 | -------------------------------------------------------------------------------- /src/msgraph_mcp_server/auth/graph_auth.py: -------------------------------------------------------------------------------- 1 | """Microsoft Graph authentication module. 2 | 3 | This module provides authentication functionality for the Microsoft Graph API 4 | using Azure Identity credentials. 5 | """ 6 | 7 | import os 8 | import logging 9 | from pathlib import Path 10 | from typing import Dict, List, Optional, Tuple, Any 11 | from dotenv import load_dotenv 12 | from azure.identity import ClientSecretCredential, CertificateCredential 13 | from msgraph import GraphServiceClient 14 | 15 | # Configure logging 16 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 17 | logger = logging.getLogger(__name__) 18 | 19 | # Try to load environment variables from multiple possible locations 20 | env_paths = [ 21 | # Project root config directory 22 | Path(__file__).parent.parent.parent / "config" / ".env", 23 | # Current working directory config 24 | Path.cwd() / "config" / ".env", 25 | # User's home directory 26 | Path.home() / ".entraid" / ".env", 27 | # System-wide config 28 | Path("/etc/entraid/.env") 29 | ] 30 | 31 | env_loaded = False 32 | for env_path in env_paths: 33 | if env_path.exists(): 34 | load_dotenv(env_path) 35 | logger.info(f"Loaded environment variables from {env_path}") 36 | env_loaded = True 37 | break 38 | 39 | if not env_loaded: 40 | logger.warning("No .env file found in any of the expected locations") 41 | 42 | class AuthenticationError(Exception): 43 | """Custom exception for authentication errors""" 44 | pass 45 | 46 | class GraphAuthManager: 47 | """Authentication manager for Microsoft Graph API.""" 48 | 49 | def __init__( 50 | self, 51 | tenant_id: Optional[str] = None, 52 | client_id: Optional[str] = None, 53 | client_secret: Optional[str] = None, 54 | certificate_path: Optional[str] = None, 55 | certificate_pwd: Optional[str] = None, 56 | scopes: Optional[List[str]] = None 57 | ): 58 | """Initialize the GraphAuthManager. 59 | 60 | Args: 61 | tenant_id: Azure tenant ID 62 | client_id: Azure application client ID 63 | client_secret: Azure application client secret 64 | certificate_path: Path to certificate file 65 | certificate_pwd: Certificate password 66 | scopes: List of Microsoft Graph API scopes to request 67 | """ 68 | # Try to get credentials from parameters first, then environment 69 | self.tenant_id = tenant_id or os.environ.get("TENANT_ID") 70 | self.client_id = client_id or os.environ.get("CLIENT_ID") 71 | self.client_secret = client_secret or os.environ.get("CLIENT_SECRET") 72 | self.certificate_path = certificate_path or os.environ.get("CERTIFICATE_PATH") 73 | self.certificate_pwd = certificate_pwd or os.environ.get("CERTIFICATE_PWD") 74 | self.scopes = scopes or ["https://graph.microsoft.com/.default"] 75 | self._graph_client = None 76 | 77 | # Log the state of credentials (without exposing sensitive data) 78 | logger.info("Initializing GraphAuthManager with credentials:") 79 | logger.info(f"TENANT_ID: {'Set' if self.tenant_id else 'Not set'}") 80 | logger.info(f"CLIENT_ID: {'Set' if self.client_id else 'Not set'}") 81 | logger.info(f"CLIENT_SECRET: {'Set' if self.client_secret else 'Not set'}") 82 | 83 | # Validate credentials 84 | self._validate_credentials() 85 | 86 | def _validate_credentials(self): 87 | """Validate that all required credentials are present.""" 88 | missing = [] 89 | if not self.tenant_id: 90 | missing.append("tenant_id") 91 | if not self.client_id: 92 | missing.append("client_id") 93 | if not self.client_secret: 94 | missing.append("client_secret") 95 | 96 | if missing: 97 | error_msg = f"Missing required credentials: {', '.join(missing)}" 98 | logger.error(error_msg) 99 | raise AuthenticationError(error_msg) 100 | 101 | def get_auth_method(self) -> str: 102 | """Determine the authentication method to use.""" 103 | if self.certificate_path and self.certificate_pwd: 104 | return 'certificate' 105 | elif self.client_secret: 106 | return 'client_secret' 107 | else: 108 | # Try to determine from environment 109 | if os.environ.get("CERTIFICATE_PWD"): 110 | return 'certificate' 111 | else: 112 | return 'client_secret' 113 | 114 | def get_auth_params(self) -> Dict[str, str]: 115 | """Get authentication parameters.""" 116 | params = { 117 | 'client_id': self.client_id, 118 | 'tenant_id': self.tenant_id 119 | } 120 | 121 | auth_method = self.get_auth_method() 122 | if auth_method == 'certificate': 123 | params['certificate_path'] = self.certificate_path 124 | params['certificate_pwd'] = self.certificate_pwd 125 | elif auth_method == 'client_secret': 126 | params['client_secret'] = self.client_secret 127 | 128 | return params 129 | 130 | def get_graph_client(self) -> GraphServiceClient: 131 | """Get a Microsoft Graph client. 132 | 133 | Returns: 134 | GraphServiceClient: Authenticated Microsoft Graph client 135 | 136 | Raises: 137 | AuthenticationError: If authentication fails or required parameters are missing 138 | """ 139 | if self._graph_client: 140 | return self._graph_client 141 | 142 | try: 143 | credential = ClientSecretCredential( 144 | tenant_id=self.tenant_id, 145 | client_id=self.client_id, 146 | client_secret=self.client_secret 147 | ) 148 | 149 | self._graph_client = GraphServiceClient( 150 | credentials=credential, 151 | scopes=self.scopes 152 | ) 153 | logger.info("Successfully created Graph client") 154 | return self._graph_client 155 | 156 | except Exception as e: 157 | error_msg = f"Failed to create Graph client: {str(e)}" 158 | logger.error(error_msg) 159 | raise AuthenticationError(error_msg) 160 | 161 | def get_auth_params_from_env(self) -> Tuple[Dict[str, Any], str]: 162 | """ 163 | Get authentication parameters from environment variables. 164 | Supports both local .env and pipeline certificate authentication. 165 | 166 | Returns: 167 | Tuple of (params dict, auth_method) 168 | """ 169 | params = {} 170 | 171 | # Get common parameters 172 | params['client_id'] = os.environ.get('CLIENT_ID') 173 | params['tenant_id'] = os.environ.get('TENANT_ID') 174 | 175 | # Check for certificate authentication (Pipeline) 176 | if os.environ.get('CERTIFICATE_PWD'): 177 | params['certificate_path'] = os.path.join(os.environ.get('AGENT_TEMPDIRECTORY', ''), 178 | os.environ.get('CERT_NAME', '')) 179 | params['certificate_pwd'] = os.environ.get('CERTIFICATE_PWD') 180 | return params, 'certificate' 181 | 182 | # Check for client secret authentication (Local) 183 | elif os.environ.get('CLIENT_SECRET'): 184 | params['client_secret'] = os.environ.get('CLIENT_SECRET') 185 | return params, 'client_secret' 186 | 187 | raise AuthenticationError("No valid authentication parameters found in environment") 188 | 189 | def get_graph_client(auth_method=None, **kwargs): 190 | """ 191 | Get a Microsoft Graph client using either certificate or client secret authentication. 192 | 193 | Args: 194 | auth_method (str): Either 'certificate' or 'client_secret'. If None, will try to determine from environment. 195 | **kwargs: Additional arguments for authentication: 196 | - For certificate: client_id, tenant_id, certificate_path, certificate_pwd 197 | - For client secret: client_id, tenant_id, client_secret 198 | 199 | Returns: 200 | GraphServiceClient: Authenticated Microsoft Graph client 201 | 202 | Raises: 203 | AuthenticationError: If authentication fails or required parameters are missing 204 | """ 205 | try: 206 | # If auth_method is not specified, try to determine from environment 207 | if auth_method is None: 208 | # Check if we're in a pipeline environment (certificate auth) 209 | if os.environ.get('CERTIFICATE_PWD'): 210 | auth_method = 'certificate' 211 | else: 212 | # Try to load from .env file 213 | load_dotenv() 214 | if os.environ.get('CLIENT_SECRET'): 215 | auth_method = 'client_secret' 216 | else: 217 | raise AuthenticationError("Could not determine authentication method. Please specify auth_method or set up environment variables.") 218 | 219 | # Set up logging 220 | logging.info(f"Using authentication method: {auth_method}") 221 | 222 | if auth_method == 'certificate': 223 | # Certificate authentication (Pipeline) 224 | required_params = ['client_id', 'tenant_id', 'certificate_path', 'certificate_pwd'] 225 | missing_params = [param for param in required_params if param not in kwargs] 226 | if missing_params: 227 | raise AuthenticationError(f"Missing required parameters for certificate authentication: {', '.join(missing_params)}") 228 | 229 | credential = CertificateCredential( 230 | tenant_id=kwargs['tenant_id'], 231 | client_id=kwargs['client_id'], 232 | certificate_path=kwargs['certificate_path'], 233 | password=kwargs['certificate_pwd'], 234 | connection_verify=certifi.where() 235 | ) 236 | logging.info("Using certificate-based authentication") 237 | 238 | elif auth_method == 'client_secret': 239 | # Client secret authentication (Local development) 240 | required_params = ['client_id', 'tenant_id', 'client_secret'] 241 | missing_params = [param for param in required_params if param not in kwargs] 242 | if missing_params: 243 | raise AuthenticationError(f"Missing required parameters for client secret authentication: {', '.join(missing_params)}") 244 | 245 | credential = ClientSecretCredential( 246 | tenant_id=kwargs['tenant_id'], 247 | client_id=kwargs['client_id'], 248 | client_secret=kwargs['client_secret'], 249 | connection_verify=certifi.where() 250 | ) 251 | logging.info("Using client secret-based authentication") 252 | 253 | else: 254 | raise AuthenticationError(f"Invalid authentication method: {auth_method}") 255 | 256 | # Create and return the Graph client 257 | scopes = ['https://graph.microsoft.com/.default'] 258 | client = GraphServiceClient( 259 | credentials=credential, 260 | scopes=scopes 261 | ) 262 | logging.info("Successfully created Graph client") 263 | return client 264 | 265 | except Exception as e: 266 | logging.error(f"Authentication failed: {str(e)}") 267 | raise AuthenticationError(f"Failed to authenticate: {str(e)}") 268 | 269 | def get_auth_params_from_env(): 270 | """ 271 | Get authentication parameters from environment variables. 272 | Supports both local .env and pipeline certificate authentication. 273 | 274 | Returns: 275 | dict: Dictionary containing authentication parameters 276 | """ 277 | params = {} 278 | 279 | # Try to load from .env file first 280 | load_dotenv() 281 | 282 | # Get common parameters 283 | params['client_id'] = os.environ.get('CLIENT_ID') 284 | params['tenant_id'] = os.environ.get('TENANT_ID') 285 | 286 | # Check for certificate authentication (Pipeline) 287 | if os.environ.get('CERTIFICATE_PWD'): 288 | params['certificate_path'] = os.path.join(os.environ.get('AGENT_TEMPDIRECTORY', ''), 289 | os.environ.get('CERT_NAME', '')) 290 | params['certificate_pwd'] = os.environ.get('CERTIFICATE_PWD') 291 | return params, 'certificate' 292 | 293 | # Check for client secret authentication (Local) 294 | elif os.environ.get('CLIENT_SECRET'): 295 | params['client_secret'] = os.environ.get('CLIENT_SECRET') 296 | return params, 'client_secret' 297 | 298 | raise AuthenticationError("No valid authentication parameters found in environment") -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EntraID MCP Server (Microsoft Graph FastMCP) 2 | 3 | This project provides a modular, resource-oriented FastMCP server for interacting with Microsoft Graph API. It is designed for extensibility, maintainability, and security, supporting advanced queries for users, sign-in logs, MFA status, and privileged users. 4 | 5 | ## Features 6 | 7 | - **Modular Resource Structure:** 8 | - Each resource (users, sign-in logs, MFA, etc.) is implemented in its own module under `src/msgraph_mcp_server/resources/`. 9 | - Easy to extend with new resources (e.g., groups, devices). 10 | - **Centralized Graph Client:** 11 | - Handles authentication and client initialization. 12 | - Shared by all resource modules. 13 | - **Comprehensive User Operations:** 14 | - Search users by name/email. 15 | - Get user by ID. 16 | - List all privileged users (directory role members). 17 | - **Full Group Lifecycle & Membership Management:** 18 | - Create, read, update, and delete groups. 19 | - Add/remove group members and owners. 20 | - Search and list groups and group members. 21 | - **Application & Service Principal Management:** 22 | - List, create, update, and delete applications (app registrations). 23 | - List, create, update, and delete service principals. 24 | - View app role assignments and delegated permissions for both applications and service principals. 25 | - **Sign-in Log Operations:** 26 | - Query sign-in logs for a user for the last X days. 27 | - **MFA Operations:** 28 | - Get MFA status for a user. 29 | - Get MFA status for all members of a group. 30 | - **Password Management:** 31 | - Reset user passwords directly with custom or auto-generated secure passwords. 32 | - Option to require password change on next sign-in. 33 | - **Permissions Helper:** 34 | - Suggest appropriate Microsoft Graph permissions for common tasks. 35 | - Search and explore available Graph permissions. 36 | - Helps implement the principle of least privilege by recommending only necessary permissions. 37 | - **Error Handling & Logging:** 38 | - Consistent error handling and progress reporting via FastMCP context. 39 | - Detailed logging for troubleshooting. 40 | - **Security:** 41 | - `.env` and secret files are excluded from version control. 42 | - Uses Microsoft best practices for authentication. 43 | 44 | ## Project Structure 45 | 46 | ``` 47 | src/msgraph_mcp_server/ 48 | ├── auth/ # Authentication logic (GraphAuthManager) 49 | ├── resources/ # Resource modules (users, signin_logs, mfa, ...) 50 | │ ├── users.py # User operations (search, get by ID, etc.) 51 | │ ├── signin_logs.py # Sign-in log operations 52 | │ ├── mfa.py # MFA status operations 53 | │ ├── permissions_helper.py # Graph permissions utilities and suggestions 54 | │ ├── applications.py # Application (app registration) operations 55 | │ ├── service_principals.py # Service principal operations 56 | │ └── ... # Other resource modules 57 | ├── utils/ # Core GraphClient and other ultilities tool, such as password generator.. 58 | ├── server.py # FastMCP server entry point (registers tools/resources) 59 | ├── __init__.py # Package marker 60 | ``` 61 | 62 | ## Usage 63 | 64 | ### 1. Setup 65 | - Clone the repo. 66 | - Create a `config/.env` file with your Azure AD credentials: 67 | ``` 68 | TENANT_ID=your-tenant-id 69 | CLIENT_ID=your-client-id 70 | CLIENT_SECRET=your-client-secret 71 | ``` 72 | - (Optional) Set up certificate-based auth if needed. 73 | 74 | ### 2. Testing & Development 75 | 76 | You can test and develop your MCP server directly using the FastMCP CLI: 77 | 78 | ```bash 79 | fastmcp dev '/path/to/src/msgraph_mcp_server/server.py' 80 | ``` 81 | 82 | This launches an interactive development environment with the MCP Inspector. For more information and advanced usage, see the [FastMCP documentation](https://github.com/jlowin/fastmcp). 83 | 84 | ### 3. Available Tools 85 | 86 | #### User Tools 87 | - `search_users(query, ctx, limit=10)` — Search users by name/email 88 | - `get_user_by_id(user_id, ctx)` — Get user details by ID 89 | - `get_privileged_users(ctx)` — List all users in privileged directory roles 90 | - `get_user_roles(user_id, ctx)` — Get all directory roles assigned to a user 91 | - `get_user_groups(user_id, ctx)` — Get all groups (including transitive memberships) for a user 92 | 93 | #### Group Tools 94 | - `get_all_groups(ctx, limit=100)` — Get all groups (with paging) 95 | - `get_group_by_id(group_id, ctx)` — Get a specific group by its ID 96 | - `search_groups_by_name(name, ctx, limit=50)` — Search for groups by display name 97 | - `get_group_members(group_id, ctx, limit=100)` — Get members of a group by group ID 98 | - `create_group(ctx, group_data)` — Create a new group (see below for group_data fields) 99 | - `update_group(group_id, ctx, group_data)` — Update an existing group (fields: displayName, mailNickname, description, visibility) 100 | - `delete_group(group_id, ctx)` — Delete a group by its ID 101 | - `add_group_member(group_id, member_id, ctx)` — Add a member (user, group, device, etc.) to a group 102 | - `remove_group_member(group_id, member_id, ctx)` — Remove a member from a group 103 | - `add_group_owner(group_id, owner_id, ctx)` — Add an owner to a group 104 | - `remove_group_owner(group_id, owner_id, ctx)` — Remove an owner from a group 105 | 106 | **Group Creation/Update Example:** 107 | - `group_data` for `create_group` and `update_group` should be a dictionary with keys such as: 108 | - `displayName` (required for create) 109 | - `mailNickname` (required for create) 110 | - `description` (optional) 111 | - `groupTypes` (optional, e.g., `["Unified"]`) 112 | - `mailEnabled` (optional) 113 | - `securityEnabled` (optional) 114 | - `visibility` (optional, "Private" or "Public") 115 | - `owners` (optional, list of user IDs) 116 | - `members` (optional, list of IDs) 117 | - `membershipRule` (required for dynamic groups) 118 | - `membershipRuleProcessingState` (optional, "On" or "Paused") 119 | 120 | See the `groups.py` docstrings for more details on supported fields and behaviors. 121 | 122 | #### Sign-in Log Tools 123 | - `get_user_sign_ins(user_id, ctx, days=7)` — Get sign-in logs for a user 124 | 125 | #### MFA Tools 126 | - `get_user_mfa_status(user_id, ctx)` — Get MFA status for a user 127 | - `get_group_mfa_status(group_id, ctx)` — Get MFA status for all group members 128 | 129 | #### Device Tools 130 | - `get_all_managed_devices(filter_os=None)` — Get all managed devices (optionally filter by OS) 131 | - `get_managed_devices_by_user(user_id)` — Get all managed devices for a specific user 132 | 133 | #### Conditional Access Policy Tools 134 | - `get_conditional_access_policies(ctx)` — Get all conditional access policies 135 | - `get_conditional_access_policy_by_id(policy_id, ctx)` — Get a single conditional access policy by its ID 136 | 137 | #### Audit Log Tools 138 | - `get_user_audit_logs(user_id, days=30)` — Get all relevant directory audit logs for a user by user_id within the last N days 139 | 140 | #### Password Management Tools 141 | - `reset_user_password_direct(user_id, password=None, require_change_on_next_sign_in=True, generate_password=False, password_length=12)` — Reset a user's password with a specific password value or generate a secure random password 142 | 143 | #### Permissions Helper Tools 144 | - `suggest_permissions_for_task(task_category, task_name)` — Suggest Microsoft Graph permissions for a specific task based on common mappings 145 | - `list_permission_categories_and_tasks()` — List all available categories and tasks for permission suggestions 146 | - `get_all_graph_permissions()` — Get all Microsoft Graph permissions directly from the Microsoft Graph API 147 | - `search_permissions(search_term, permission_type=None)` — Search for Microsoft Graph permissions by keyword 148 | 149 | #### Application Tools 150 | - `list_applications(ctx, limit=100)` — List all applications (app registrations) in the tenant, with paging 151 | - `get_application_by_id(app_id, ctx)` — Get a specific application by its object ID (includes app role assignments and delegated permissions) 152 | - `create_application(ctx, app_data)` — Create a new application (see below for app_data fields) 153 | - `update_application(app_id, ctx, app_data)` — Update an existing application (fields: displayName, signInAudience, tags, identifierUris, web, api, requiredResourceAccess) 154 | - `delete_application(app_id, ctx)` — Delete an application by its object ID 155 | 156 | **Application Creation/Update Example:** 157 | - `app_data` for `create_application` and `update_application` should be a dictionary with keys such as: 158 | - `displayName` (required for create) 159 | - `signInAudience` (optional) 160 | - `tags` (optional) 161 | - `identifierUris` (optional) 162 | - `web` (optional) 163 | - `api` (optional) 164 | - `requiredResourceAccess` (optional) 165 | 166 | #### Service Principal Tools 167 | - `list_service_principals(ctx, limit=100)` — List all service principals in the tenant, with paging 168 | - `get_service_principal_by_id(sp_id, ctx)` — Get a specific service principal by its object ID (includes app role assignments and delegated permissions) 169 | - `create_service_principal(ctx, sp_data)` — Create a new service principal (see below for sp_data fields) 170 | - `update_service_principal(sp_id, ctx, sp_data)` — Update an existing service principal (fields: displayName, accountEnabled, tags, appRoleAssignmentRequired) 171 | - `delete_service_principal(sp_id, ctx)` — Delete a service principal by its object ID 172 | 173 | **Service Principal Creation/Update Example:** 174 | - `sp_data` for `create_service_principal` and `update_service_principal` should be a dictionary with keys such as: 175 | - `appId` (required for create) 176 | - `accountEnabled` (optional) 177 | - `tags` (optional) 178 | - `appRoleAssignmentRequired` (optional) 179 | - `displayName` (optional) 180 | 181 | #### Example Resource 182 | - `greeting://{name}` — Returns a personalized greeting 183 | 184 | ## Extending the Server 185 | - Add new resource modules under `resources/` (e.g., `groups.py`, `devices.py`). 186 | - Register new tools in `server.py` using the FastMCP `@mcp.tool()` decorator. 187 | - Use the shared `GraphClient` for all API calls. 188 | 189 | ## Security & Best Practices 190 | - **Never commit secrets:** `.env` and other sensitive files are gitignored. 191 | - **Use least privilege:** Grant only the necessary Microsoft Graph permissions to your Azure AD app. 192 | - **Audit & monitor:** Use the logging output for troubleshooting and monitoring. 193 | 194 | ## Required Graph API Permissions 195 | | API / Permission | Type | Description | 196 | |-----------------------------|-------------|-------------------------------------------| 197 | | AuditLog.Read.All | Application | Read all audit log data | 198 | | AuthenticationContext.Read.All | Application | Read all authentication context information | 199 | | DeviceManagementManagedDevices.Read.All | Application | Read Microsoft Intune devices | 200 | | Directory.Read.All | Application | Read directory data | 201 | | Group.Read.All | Application | Read all groups | 202 | | GroupMember.Read.All | Application | Read all group memberships | 203 | | Group.ReadWrite.All | Application | Create, update, delete groups; manage group members and owners | 204 | | Policy.Read.All | Application | Read your organization's policies | 205 | | RoleManagement.Read.Directory | Application | Read all directory RBAC settings | 206 | | User.Read.All | Application | Read all users' full profiles | 207 | | User-PasswordProfile.ReadWrite.All | Application | Least privileged permission to update the passwordProfile property | 208 | | UserAuthenticationMethod.Read.All | Application | Read all users' authentication methods | 209 | | Application.ReadWrite.All | Application | Create, update, and delete applications (app registrations) and service principals | 210 | 211 | **Note:** `Group.ReadWrite.All` is required for group creation, update, deletion, and for adding/removing group members or owners. `Group.Read.All` and `GroupMember.Read.All` are sufficient for read-only group and membership queries. 212 | 213 | ## Advanced: Using with Claude or Cursor 214 | 215 | ### Using with Claude (Anthropic) 216 | To install and run this server as a Claude MCP tool, use: 217 | 218 | ```bash 219 | fastmcp install '/path/to/src/msgraph_mcp_server/server.py' \ 220 | --with msgraph-sdk --with azure-identity --with azure-core --with msgraph-core \ 221 | -f /path/to/.env 222 | ``` 223 | - Replace `/path/to/` with your actual project path. 224 | - The `-f` flag points to your `.env` file (never commit secrets!). 225 | 226 | ### Using with Cursor 227 | Add the following to your `.cursor/mcp.json` (do **not** include actual secrets in version control): 228 | 229 | ```json 230 | { 231 | "EntraID MCP Server": { 232 | "command": "uv", 233 | "args": [ 234 | "run", 235 | "--with", "azure-core", 236 | "--with", "azure-identity", 237 | "--with", "fastmcp", 238 | "--with", "msgraph-core", 239 | "--with", "msgraph-sdk", 240 | "fastmcp", 241 | "run", 242 | "/path/to/src/msgraph_mcp_server/server.py" 243 | ], 244 | "env": { 245 | "TENANT_ID": "", 246 | "CLIENT_ID": "", 247 | "CLIENT_SECRET": "" 248 | } 249 | } 250 | } 251 | ``` 252 | - Replace `/path/to/` and the environment variables with your actual values. 253 | - **Never commit real secrets to your repository!** 254 | 255 | ## License 256 | 257 | MIT 258 | -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/permissions_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List, Any, Optional 3 | from kiota_abstractions.base_request_configuration import RequestConfiguration 4 | from utils.graph_client import GraphClient 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | # Microsoft Graph application ID - this is the constant ID for the Microsoft Graph service principal 9 | MS_GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000" 10 | 11 | # Common permission mappings - mapping of common tasks to their required permissions 12 | # Format: { 13 | # "task_category": { 14 | # "task_name": { 15 | # "delegated": ["Permission1", "Permission2"], 16 | # "application": ["Permission1", "Permission2"], 17 | # "description": "Description of the task" 18 | # } 19 | # } 20 | # } 21 | COMMON_PERMISSION_MAPPINGS = { 22 | "users": { 23 | "read_user_profile": { 24 | "delegated": ["User.Read", "User.ReadBasic.All"], 25 | "application": ["User.Read.All"], 26 | "description": "Read user profile information" 27 | }, 28 | "update_user_profile": { 29 | "delegated": ["User.ReadWrite", "User.ReadWrite.All"], 30 | "application": ["User.ReadWrite.All"], 31 | "description": "Update user profile information" 32 | }, 33 | "read_all_users": { 34 | "delegated": ["User.ReadBasic.All", "User.Read.All"], 35 | "application": ["User.Read.All"], 36 | "description": "Read all users' profiles in the organization" 37 | }, 38 | "reset_user_password": { 39 | "delegated": ["User.ReadWrite.All"], 40 | "application": ["User.ReadWrite.All", "Directory.ReadWrite.All"], 41 | "description": "Reset a user's password" 42 | } 43 | }, 44 | "groups": { 45 | "read_user_groups": { 46 | "delegated": ["GroupMember.Read.All"], 47 | "application": ["GroupMember.Read.All", "Directory.Read.All"], 48 | "description": "Read groups a user is a member of" 49 | }, 50 | "read_all_groups": { 51 | "delegated": ["Group.Read.All"], 52 | "application": ["Group.Read.All"], 53 | "description": "Read all groups in the organization" 54 | }, 55 | "manage_groups": { 56 | "delegated": ["Group.ReadWrite.All"], 57 | "application": ["Group.ReadWrite.All"], 58 | "description": "Create, update, and delete groups, and add/remove members" 59 | } 60 | }, 61 | "mail": { 62 | "read_user_mail": { 63 | "delegated": ["Mail.Read"], 64 | "application": ["Mail.Read"], 65 | "description": "Read user's mail" 66 | }, 67 | "send_mail": { 68 | "delegated": ["Mail.Send"], 69 | "application": ["Mail.Send"], 70 | "description": "Send mail as the user" 71 | } 72 | }, 73 | "calendar": { 74 | "read_user_calendar": { 75 | "delegated": ["Calendars.Read"], 76 | "application": ["Calendars.Read"], 77 | "description": "Read user's calendar" 78 | }, 79 | "edit_user_calendar": { 80 | "delegated": ["Calendars.ReadWrite"], 81 | "application": ["Calendars.ReadWrite"], 82 | "description": "Read and write to user's calendar" 83 | } 84 | }, 85 | "files": { 86 | "read_user_files": { 87 | "delegated": ["Files.Read", "Files.Read.All"], 88 | "application": ["Files.Read.All"], 89 | "description": "Read user's files" 90 | }, 91 | "edit_user_files": { 92 | "delegated": ["Files.ReadWrite", "Files.ReadWrite.All"], 93 | "application": ["Files.ReadWrite.All"], 94 | "description": "Read and write to user's files" 95 | } 96 | }, 97 | "devices": { 98 | "read_devices": { 99 | "delegated": ["Device.Read"], 100 | "application": ["Device.Read.All"], 101 | "description": "Read device information" 102 | }, 103 | "manage_devices": { 104 | "delegated": ["Device.ReadWrite.All"], 105 | "application": ["Device.ReadWrite.All"], 106 | "description": "Manage device configuration" 107 | } 108 | }, 109 | "audit_logs": { 110 | "read_audit_logs": { 111 | "delegated": ["AuditLog.Read.All"], 112 | "application": ["AuditLog.Read.All"], 113 | "description": "Read audit logs" 114 | }, 115 | "read_sign_in_logs": { 116 | "delegated": ["AuditLog.Read.All"], 117 | "application": ["AuditLog.Read.All"], 118 | "description": "Read sign-in activity logs" 119 | } 120 | }, 121 | "directory": { 122 | "read_directory": { 123 | "delegated": ["Directory.Read.All"], 124 | "application": ["Directory.Read.All"], 125 | "description": "Read directory data (users, groups, apps, etc.)" 126 | }, 127 | "write_directory": { 128 | "delegated": ["Directory.ReadWrite.All"], 129 | "application": ["Directory.ReadWrite.All"], 130 | "description": "Read and write directory data (users, groups, apps, etc.)" 131 | } 132 | } 133 | } 134 | 135 | async def suggest_permissions_for_task(task_category: str, task_name: str) -> Dict[str, Any]: 136 | """Suggest permissions for a specific task based on common mappings. 137 | 138 | Args: 139 | task_category: The category of the task (users, groups, mail, etc.) 140 | task_name: The specific task name 141 | 142 | Returns: 143 | A dictionary with suggested delegated and application permissions 144 | """ 145 | try: 146 | if task_category not in COMMON_PERMISSION_MAPPINGS: 147 | return { 148 | "status": "error", 149 | "message": f"Unknown task category: {task_category}", 150 | "available_categories": list(COMMON_PERMISSION_MAPPINGS.keys()) 151 | } 152 | 153 | if task_name not in COMMON_PERMISSION_MAPPINGS[task_category]: 154 | return { 155 | "status": "error", 156 | "message": f"Unknown task name: {task_name}", 157 | "available_tasks": list(COMMON_PERMISSION_MAPPINGS[task_category].keys()) 158 | } 159 | 160 | task_info = COMMON_PERMISSION_MAPPINGS[task_category][task_name] 161 | 162 | return { 163 | "status": "success", 164 | "task_category": task_category, 165 | "task_name": task_name, 166 | "description": task_info["description"], 167 | "delegated_permissions": task_info["delegated"], 168 | "application_permissions": task_info["application"], 169 | "notes": "These are suggested permissions based on common usage patterns. Always follow the principle of least privilege." 170 | } 171 | except Exception as e: 172 | logger.error(f"Error suggesting permissions for task {task_category}/{task_name}: {str(e)}") 173 | raise 174 | 175 | async def list_available_categories_and_tasks() -> Dict[str, Any]: 176 | """List all available categories and tasks for permission suggestions. 177 | 178 | Returns: 179 | A dictionary with all available categories and their tasks 180 | """ 181 | try: 182 | result = { 183 | "status": "success", 184 | "categories": {} 185 | } 186 | 187 | for category, tasks in COMMON_PERMISSION_MAPPINGS.items(): 188 | result["categories"][category] = { 189 | "tasks": [] 190 | } 191 | 192 | for task_name, task_info in tasks.items(): 193 | result["categories"][category]["tasks"].append({ 194 | "name": task_name, 195 | "description": task_info["description"] 196 | }) 197 | 198 | return result 199 | except Exception as e: 200 | logger.error(f"Error listing available categories and tasks: {str(e)}") 201 | raise 202 | 203 | async def get_all_graph_permissions(graph_client: GraphClient) -> Dict[str, Any]: 204 | """Get all Microsoft Graph permissions directly from the Microsoft Graph API. 205 | 206 | Args: 207 | graph_client: GraphClient instance 208 | 209 | Returns: 210 | A dictionary with all delegated and application permissions 211 | """ 212 | try: 213 | client = graph_client.get_client() 214 | 215 | # Get the Microsoft Graph service principal 216 | query_params = { 217 | "$select": "id,appId,displayName,appRoles,oauth2PermissionScopes" 218 | } 219 | 220 | ms_graph_sp = await client.service_principals.by_service_principal_id(MS_GRAPH_APP_ID).get() 221 | 222 | if not ms_graph_sp: 223 | logger.error("Microsoft Graph service principal not found") 224 | return {"status": "error", "message": "Microsoft Graph service principal not found"} 225 | 226 | # Extract delegated permissions (oauth2PermissionScopes) 227 | delegated_permissions = [] 228 | if hasattr(ms_graph_sp, "oauth2_permission_scopes") and ms_graph_sp.oauth2_permission_scopes: 229 | for permission in ms_graph_sp.oauth2_permission_scopes: 230 | delegated_permissions.append({ 231 | "id": getattr(permission, "id", None), 232 | "value": getattr(permission, "value", None), 233 | "type": "delegated", 234 | "adminConsentDisplayName": getattr(permission, "admin_consent_display_name", None), 235 | "adminConsentDescription": getattr(permission, "admin_consent_description", None), 236 | "userConsentDisplayName": getattr(permission, "user_consent_display_name", None), 237 | "userConsentDescription": getattr(permission, "user_consent_description", None), 238 | "isEnabled": getattr(permission, "is_enabled", None) 239 | }) 240 | 241 | # Extract application permissions (appRoles) 242 | application_permissions = [] 243 | if hasattr(ms_graph_sp, "app_roles") and ms_graph_sp.app_roles: 244 | for permission in ms_graph_sp.app_roles: 245 | application_permissions.append({ 246 | "id": getattr(permission, "id", None), 247 | "value": getattr(permission, "value", None), 248 | "type": "application", 249 | "displayName": getattr(permission, "display_name", None), 250 | "description": getattr(permission, "description", None), 251 | "isEnabled": getattr(permission, "is_enabled", None) 252 | }) 253 | 254 | return { 255 | "status": "success", 256 | "delegated_permissions": delegated_permissions, 257 | "application_permissions": application_permissions 258 | } 259 | except Exception as e: 260 | logger.error(f"Error getting Graph permissions: {str(e)}") 261 | raise 262 | 263 | async def search_permissions(graph_client: GraphClient, search_term: str, permission_type: Optional[str] = None) -> Dict[str, Any]: 264 | """Search for Microsoft Graph permissions by keyword. 265 | 266 | Args: 267 | graph_client: GraphClient instance 268 | search_term: The keyword to search for 269 | permission_type: Optional filter by permission type ("delegated" or "application") 270 | 271 | Returns: 272 | A dictionary with matching permissions 273 | """ 274 | try: 275 | all_permissions = await get_all_graph_permissions(graph_client) 276 | 277 | if all_permissions.get("status") != "success": 278 | return all_permissions 279 | 280 | delegated_permissions = all_permissions.get("delegated_permissions", []) 281 | application_permissions = all_permissions.get("application_permissions", []) 282 | 283 | # Convert search term to lowercase for case-insensitive matching 284 | search_term = search_term.lower() 285 | 286 | # Filter permissions based on search term 287 | matching_delegated = [] 288 | if permission_type is None or permission_type.lower() == "delegated": 289 | for permission in delegated_permissions: 290 | # Search in value, display name, and description 291 | if (search_term in permission.get("value", "").lower() or 292 | search_term in permission.get("adminConsentDisplayName", "").lower() or 293 | search_term in permission.get("adminConsentDescription", "").lower()): 294 | matching_delegated.append(permission) 295 | 296 | matching_application = [] 297 | if permission_type is None or permission_type.lower() == "application": 298 | for permission in application_permissions: 299 | # Search in value, display name, and description 300 | if (search_term in permission.get("value", "").lower() or 301 | search_term in permission.get("displayName", "").lower() or 302 | search_term in permission.get("description", "").lower()): 303 | matching_application.append(permission) 304 | 305 | return { 306 | "status": "success", 307 | "search_term": search_term, 308 | "matching_delegated_permissions": matching_delegated, 309 | "matching_application_permissions": matching_application, 310 | "total_matches": len(matching_delegated) + len(matching_application) 311 | } 312 | except Exception as e: 313 | logger.error(f"Error searching for permissions with term '{search_term}': {str(e)}") 314 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/conditional_access.py: -------------------------------------------------------------------------------- 1 | """Conditional Access resource module for Microsoft Graph. 2 | 3 | This module provides access to Microsoft Graph conditional access policy resources. 4 | """ 5 | 6 | import logging 7 | from typing import Dict, List, Any 8 | from msgraph.generated.identity.conditional_access.policies.policies_request_builder import PoliciesRequestBuilder 9 | from utils.graph_client import GraphClient 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | def format_list_for_csv(lst): 14 | if not lst: 15 | return "" 16 | return "; ".join(str(item) for item in lst) 17 | 18 | async def get_group_details(client, group_ids): 19 | group_details = {} 20 | for group_id in group_ids: 21 | if not group_id: 22 | continue 23 | if group_id in ['All', 'None', 'GuestsOrExternalUsers', 'GuestOrExternalUserTypes']: 24 | group_details[group_id] = group_id 25 | continue 26 | try: 27 | group = await client.groups.by_group_id(group_id).get() 28 | group_details[group_id] = f"{getattr(group, 'display_name', group_id)} ({group_id})" 29 | except Exception as e: 30 | logger.warning(f"Could not fetch details for group {group_id}: {str(e)}") 31 | group_details[group_id] = f"Unknown Group ({group_id})" 32 | return group_details 33 | 34 | async def parse_conditions(client, conditions): 35 | parsed = { 36 | 'Users_Include': [], 'Users_Exclude': [], 'Groups_Include': [], 'Groups_Include_Names': [], 37 | 'Groups_Exclude': [], 'Groups_Exclude_Names': [], 'Roles_Include': [], 'Roles_Exclude': [], 38 | 'Include_Guest_Or_External_Users': '', 'Exclude_Guest_Or_External_Users': '', 39 | 'Apps_Include': [], 'Apps_Exclude': [], 'User_Actions': [], 'Authentication_Context_References': [], 40 | 'Application_Filter': '', 'User_Risk_Levels': [], 'Sign_In_Risk_Levels': [], 41 | 'Service_Principal_Risk_Levels': [], 'Insider_Risk_Levels': '', 'Client_App_Types': [], 42 | 'Platforms': '', 'Locations': '', 'Devices': '', 'Client_Applications': '' 43 | } 44 | try: 45 | if hasattr(conditions, 'user_risk_levels'): 46 | parsed['User_Risk_Levels'] = conditions.user_risk_levels or [] 47 | if hasattr(conditions, 'sign_in_risk_levels'): 48 | parsed['Sign_In_Risk_Levels'] = conditions.sign_in_risk_levels or [] 49 | if hasattr(conditions, 'service_principal_risk_levels'): 50 | parsed['Service_Principal_Risk_Levels'] = conditions.service_principal_risk_levels or [] 51 | if hasattr(conditions, 'insider_risk_levels'): 52 | parsed['Insider_Risk_Levels'] = conditions.insider_risk_levels or '' 53 | if hasattr(conditions, 'client_app_types'): 54 | parsed['Client_App_Types'] = conditions.client_app_types or [] 55 | if hasattr(conditions, 'applications'): 56 | if hasattr(conditions.applications, 'include_applications'): 57 | parsed['Apps_Include'] = conditions.applications.include_applications or [] 58 | if hasattr(conditions.applications, 'exclude_applications'): 59 | parsed['Apps_Exclude'] = conditions.applications.exclude_applications or [] 60 | if hasattr(conditions.applications, 'include_user_actions'): 61 | parsed['User_Actions'] = conditions.applications.include_user_actions or [] 62 | if hasattr(conditions.applications, 'include_authentication_context_class_references'): 63 | parsed['Authentication_Context_References'] = conditions.applications.include_authentication_context_class_references or [] 64 | if hasattr(conditions.applications, 'application_filter'): 65 | parsed['Application_Filter'] = conditions.applications.application_filter or '' 66 | if hasattr(conditions, 'users'): 67 | if hasattr(conditions.users, 'include_users'): 68 | parsed['Users_Include'] = conditions.users.include_users or [] 69 | if hasattr(conditions.users, 'exclude_users'): 70 | parsed['Users_Exclude'] = conditions.users.exclude_users or [] 71 | if hasattr(conditions.users, 'include_groups'): 72 | parsed['Groups_Include'] = conditions.users.include_groups or [] 73 | if hasattr(conditions.users, 'exclude_groups'): 74 | parsed['Groups_Exclude'] = conditions.users.exclude_groups or [] 75 | if hasattr(conditions.users, 'include_roles'): 76 | parsed['Roles_Include'] = conditions.users.include_roles or [] 77 | if hasattr(conditions.users, 'exclude_roles'): 78 | parsed['Roles_Exclude'] = conditions.users.exclude_roles or [] 79 | if hasattr(conditions.users, 'include_guests_or_external_users'): 80 | parsed['Include_Guest_Or_External_Users'] = str(conditions.users.include_guests_or_external_users or '') 81 | if hasattr(conditions.users, 'exclude_guests_or_external_users'): 82 | parsed['Exclude_Guest_Or_External_Users'] = str(conditions.users.exclude_guests_or_external_users or '') 83 | all_groups = list(set(parsed['Groups_Include'] + parsed['Groups_Exclude'])) 84 | if all_groups: 85 | group_details = await get_group_details(client, all_groups) 86 | parsed['Groups_Include_Names'] = [group_details.get(group_id, f"Unknown Group ({group_id})") for group_id in parsed['Groups_Include'] if group_id] 87 | parsed['Groups_Exclude_Names'] = [group_details.get(group_id, f"Unknown Group ({group_id})") for group_id in parsed['Groups_Exclude'] if group_id] 88 | parsed['Platforms'] = str(conditions.platforms or '') 89 | parsed['Locations'] = str(conditions.locations or '') 90 | parsed['Devices'] = str(conditions.devices or '') 91 | parsed['Client_Applications'] = str(conditions.client_applications or '') 92 | except Exception as e: 93 | logger.warning(f"Error parsing conditions: {str(e)}") 94 | return {k: format_list_for_csv(v) if isinstance(v, list) else v for k, v in parsed.items()} 95 | 96 | def parse_grant_controls(grant_controls): 97 | try: 98 | if not grant_controls: 99 | return { 100 | 'Operator': '', 'Built_In_Controls': '', 'Custom_Authentication_Factors': '', 'Terms_Of_Use': '', 101 | 'Auth_Strength_Id': '', 'Auth_Strength_DisplayName': '', 'Auth_Strength_Description': '', 102 | 'Auth_Strength_PolicyType': '', 'Auth_Strength_Requirements': '', 'Auth_Strength_Combinations': '' 103 | } 104 | parsed = {} 105 | parsed['Operator'] = grant_controls.operator if hasattr(grant_controls, 'operator') else '' 106 | parsed['Built_In_Controls'] = format_list_for_csv(grant_controls.built_in_controls) if hasattr(grant_controls, 'built_in_controls') else '' 107 | parsed['Custom_Authentication_Factors'] = format_list_for_csv(grant_controls.custom_authentication_factors) if hasattr(grant_controls, 'custom_authentication_factors') else '' 108 | parsed['Terms_Of_Use'] = format_list_for_csv(grant_controls.terms_of_use) if hasattr(grant_controls, 'terms_of_use') else '' 109 | if hasattr(grant_controls, 'authentication_strength'): 110 | auth_strength = grant_controls.authentication_strength 111 | parsed['Auth_Strength_Id'] = getattr(auth_strength, 'id', '') 112 | parsed['Auth_Strength_DisplayName'] = getattr(auth_strength, 'display_name', '') 113 | parsed['Auth_Strength_Description'] = getattr(auth_strength, 'description', '') 114 | parsed['Auth_Strength_PolicyType'] = getattr(auth_strength, 'policy_type', '') 115 | parsed['Auth_Strength_Requirements'] = getattr(auth_strength, 'requirements_satisfied', '') 116 | parsed['Auth_Strength_Combinations'] = format_list_for_csv(getattr(auth_strength, 'allowed_combinations', [])) 117 | else: 118 | parsed.update({ 119 | 'Auth_Strength_Id': '', 'Auth_Strength_DisplayName': '', 'Auth_Strength_Description': '', 120 | 'Auth_Strength_PolicyType': '', 'Auth_Strength_Requirements': '', 'Auth_Strength_Combinations': '' 121 | }) 122 | return parsed 123 | except Exception as e: 124 | logger.warning(f"Error parsing grant controls: {str(e)}") 125 | return { 126 | 'Operator': '', 'Built_In_Controls': '', 'Custom_Authentication_Factors': '', 'Terms_Of_Use': '', 127 | 'Auth_Strength_Id': '', 'Auth_Strength_DisplayName': '', 'Auth_Strength_Description': '', 128 | 'Auth_Strength_PolicyType': '', 'Auth_Strength_Requirements': '', 'Auth_Strength_Combinations': '' 129 | } 130 | 131 | def parse_session_controls(session_controls): 132 | try: 133 | if not session_controls: 134 | return { 135 | 'Disable_Resilience_Defaults': '', 'Application_Enforced_Restrictions': '', 'Cloud_App_Security': '', 136 | 'Persistent_Browser': '', 'Sign_In_Frequency_Value': '', 'Sign_In_Frequency_Type': '', 137 | 'Sign_In_Frequency_Auth_Type': '', 'Sign_In_Frequency_Interval': '', 'Sign_In_Frequency_IsEnabled': '' 138 | } 139 | parsed = {} 140 | parsed['Disable_Resilience_Defaults'] = str(getattr(session_controls, 'disable_resilience_defaults', '')) 141 | parsed['Application_Enforced_Restrictions'] = str(getattr(session_controls, 'application_enforced_restrictions', '')) 142 | parsed['Cloud_App_Security'] = str(getattr(session_controls, 'cloud_app_security', '')) 143 | parsed['Persistent_Browser'] = str(getattr(session_controls, 'persistent_browser', '')) 144 | if hasattr(session_controls, 'sign_in_frequency'): 145 | sign_in_freq = session_controls.sign_in_frequency 146 | parsed['Sign_In_Frequency_Value'] = str(getattr(sign_in_freq, 'value', '')) 147 | parsed['Sign_In_Frequency_Type'] = str(getattr(sign_in_freq, 'type', '')) 148 | parsed['Sign_In_Frequency_Auth_Type'] = str(getattr(sign_in_freq, 'authentication_type', '')) 149 | parsed['Sign_In_Frequency_Interval'] = str(getattr(sign_in_freq, 'frequency_interval', '')) 150 | parsed['Sign_In_Frequency_IsEnabled'] = 'Yes' if getattr(sign_in_freq, 'is_enabled', False) else 'No' 151 | else: 152 | parsed.update({ 153 | 'Sign_In_Frequency_Value': '', 'Sign_In_Frequency_Type': '', 'Sign_In_Frequency_Auth_Type': '', 154 | 'Sign_In_Frequency_Interval': '', 'Sign_In_Frequency_IsEnabled': '' 155 | }) 156 | return parsed 157 | except Exception as e: 158 | logger.warning(f"Error parsing session controls: {str(e)}") 159 | return { 160 | 'Disable_Resilience_Defaults': '', 'Application_Enforced_Restrictions': '', 'Cloud_App_Security': '', 161 | 'Persistent_Browser': '', 'Sign_In_Frequency_Value': '', 'Sign_In_Frequency_Type': '', 162 | 'Sign_In_Frequency_Auth_Type': '', 'Sign_In_Frequency_Interval': '', 'Sign_In_Frequency_IsEnabled': '' 163 | } 164 | 165 | async def get_conditional_access_policies(graph_client: GraphClient) -> List[Dict[str, Any]]: 166 | """Get all conditional access policies with comprehensive details.""" 167 | try: 168 | client = graph_client.get_client() 169 | policies_response = await client.identity.conditional_access.policies.get() 170 | policies = [] 171 | if policies_response and policies_response.value: 172 | for policy in policies_response.value: 173 | # Parse conditions 174 | conditions = await parse_conditions(client, getattr(policy, 'conditions', None)) 175 | # Parse grant controls 176 | grant_controls = parse_grant_controls(getattr(policy, 'grant_controls', None)) 177 | # Parse session controls 178 | session_controls = parse_session_controls(getattr(policy, 'session_controls', None)) 179 | # Compose policy data 180 | policy_data = { 181 | 'id': getattr(policy, 'id', None), 182 | 'displayName': getattr(policy, 'display_name', None), 183 | 'state': getattr(policy, 'state', None).value if getattr(policy, 'state', None) else None, 184 | 'createdDateTime': policy.created_date_time.isoformat() if getattr(policy, 'created_date_time', None) else None, 185 | 'modifiedDateTime': policy.modified_date_time.isoformat() if getattr(policy, 'modified_date_time', None) else None, 186 | **conditions, 187 | **{f'Grant_{k}': v for k, v in grant_controls.items()}, 188 | **session_controls 189 | } 190 | policies.append(policy_data) 191 | return policies 192 | except Exception as e: 193 | logger.error(f"Error fetching conditional access policies: {str(e)}") 194 | raise 195 | 196 | async def get_conditional_access_policy_by_id(graph_client: GraphClient, policy_id: str) -> Dict[str, Any]: 197 | """Get a single conditional access policy by its ID with comprehensive details.""" 198 | try: 199 | client = graph_client.get_client() 200 | policy = await client.identity.conditional_access.policies.by_conditional_access_policy_id(policy_id).get() 201 | if not policy: 202 | return {} 203 | # Parse conditions 204 | conditions = await parse_conditions(client, getattr(policy, 'conditions', None)) 205 | # Parse grant controls 206 | grant_controls = parse_grant_controls(getattr(policy, 'grant_controls', None)) 207 | # Parse session controls 208 | session_controls = parse_session_controls(getattr(policy, 'session_controls', None)) 209 | # Compose policy data 210 | policy_data = { 211 | 'id': getattr(policy, 'id', None), 212 | 'displayName': getattr(policy, 'display_name', None), 213 | 'state': getattr(policy, 'state', None).value if getattr(policy, 'state', None) else None, 214 | 'createdDateTime': policy.created_date_time.isoformat() if getattr(policy, 'created_date_time', None) else None, 215 | 'modifiedDateTime': policy.modified_date_time.isoformat() if getattr(policy, 'modified_date_time', None) else None, 216 | **conditions, 217 | **{f'Grant_{k}': v for k, v in grant_controls.items()}, 218 | **session_controls 219 | } 220 | return policy_data 221 | except Exception as e: 222 | logger.error(f"Error fetching conditional access policy by ID {policy_id}: {str(e)}") 223 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/resources/groups.py: -------------------------------------------------------------------------------- 1 | """Groups resource module for Microsoft Graph. 2 | 3 | This module provides access to Microsoft Graph group resources. 4 | """ 5 | 6 | import logging 7 | from typing import Dict, List, Any, Optional 8 | from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder 9 | from msgraph.generated.models.group import Group 10 | from msgraph.generated.models.directory_object import DirectoryObject 11 | from utils.graph_client import GraphClient 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | async def get_all_groups(graph_client: GraphClient, limit: int = 100) -> List[Dict[str, Any]]: 16 | """Get all groups (up to the specified limit, with paging).""" 17 | try: 18 | client = graph_client.get_client() 19 | query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters(top=limit) 20 | request_configuration = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(query_parameters=query_params) 21 | response = await client.groups.get(request_configuration=request_configuration) 22 | groups = [] 23 | if response and response.value: 24 | groups.extend(response.value) 25 | # Paging: fetch more if odata_next_link is present 26 | while response is not None and getattr(response, 'odata_next_link', None): 27 | response = await client.groups.with_url(response.odata_next_link).get() 28 | if response and response.value: 29 | groups.extend(response.value) 30 | # Format output 31 | formatted_groups = [] 32 | for group in groups[:limit]: 33 | group_data = { 34 | 'id': group.id, 35 | 'displayName': group.display_name, 36 | 'mail': group.mail, 37 | 'mailNickname': group.mail_nickname, 38 | 'description': group.description, 39 | 'groupTypes': group.group_types, 40 | 'securityEnabled': group.security_enabled, 41 | 'mailEnabled': group.mail_enabled, 42 | 'createdDateTime': group.created_date_time.isoformat() if group.created_date_time else None 43 | } 44 | formatted_groups.append(group_data) 45 | return formatted_groups 46 | except Exception as e: 47 | logger.error(f"Error fetching all groups: {str(e)}") 48 | raise 49 | 50 | async def get_group_by_id(graph_client: GraphClient, group_id: str) -> Optional[Dict[str, Any]]: 51 | """Get a specific group by ID.""" 52 | try: 53 | client = graph_client.get_client() 54 | group = await client.groups.by_group_id(group_id).get() 55 | 56 | if group: 57 | group_data = { 58 | 'id': group.id, 59 | 'displayName': group.display_name, 60 | 'mail': group.mail, 61 | 'mailNickname': group.mail_nickname, 62 | 'description': group.description, 63 | 'groupTypes': group.group_types, 64 | 'securityEnabled': group.security_enabled, 65 | 'mailEnabled': group.mail_enabled, 66 | 'visibility': group.visibility, 67 | 'createdDateTime': group.created_date_time.isoformat() if group.created_date_time else None 68 | } 69 | return group_data 70 | return None 71 | except Exception as e: 72 | logger.error(f"Error fetching group {group_id}: {str(e)}") 73 | raise 74 | 75 | async def search_groups_by_name(graph_client: GraphClient, name: str, limit: int = 50) -> List[Dict[str, Any]]: 76 | """Search for groups by display name (case-insensitive, partial match, with paging).""" 77 | try: 78 | client = graph_client.get_client() 79 | filter_query = f"startswith(displayName,'{name}')" 80 | query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters( 81 | filter=filter_query, top=limit 82 | ) 83 | request_configuration = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(query_parameters=query_params) 84 | response = await client.groups.get(request_configuration=request_configuration) 85 | groups = [] 86 | if response and response.value: 87 | groups.extend(response.value) 88 | # Paging 89 | while response is not None and getattr(response, 'odata_next_link', None): 90 | response = await client.groups.with_url(response.odata_next_link).get() 91 | if response and response.value: 92 | groups.extend(response.value) 93 | formatted_groups = [] 94 | for group in groups[:limit]: 95 | group_data = { 96 | 'id': group.id, 97 | 'displayName': group.display_name, 98 | 'mail': group.mail, 99 | 'mailNickname': group.mail_nickname, 100 | 'description': group.description, 101 | 'groupTypes': group.group_types, 102 | 'securityEnabled': group.security_enabled, 103 | 'mailEnabled': group.mail_enabled, 104 | 'createdDateTime': group.created_date_time.isoformat() if group.created_date_time else None 105 | } 106 | formatted_groups.append(group_data) 107 | return formatted_groups 108 | except Exception as e: 109 | logger.error(f"Error searching groups by name: {str(e)}") 110 | raise 111 | 112 | async def get_group_members(graph_client: GraphClient, group_id: str, limit: int = 100) -> List[Dict[str, Any]]: 113 | """Get members of a group by group ID (up to the specified limit, with paging).""" 114 | try: 115 | client = graph_client.get_client() 116 | members_response = await client.groups.by_group_id(group_id).members.get() 117 | members = [] 118 | if members_response and members_response.value: 119 | members.extend(members_response.value) 120 | # Paging 121 | while members_response is not None and getattr(members_response, 'odata_next_link', None): 122 | members_response = await client.groups.by_group_id(group_id).members.with_url(members_response.odata_next_link).get() 123 | if members_response and members_response.value: 124 | members.extend(members_response.value) 125 | formatted_members = [] 126 | for member in members[:limit]: 127 | member_data = { 128 | 'id': getattr(member, 'id', None), 129 | 'displayName': getattr(member, 'display_name', None), 130 | 'mail': getattr(member, 'mail', None), 131 | 'userPrincipalName': getattr(member, 'user_principal_name', None), 132 | 'givenName': getattr(member, 'given_name', None), 133 | 'surname': getattr(member, 'surname', None), 134 | 'jobTitle': getattr(member, 'job_title', None), 135 | 'officeLocation': getattr(member, 'office_location', None), 136 | 'businessPhones': getattr(member, 'business_phones', None), 137 | 'mobilePhone': getattr(member, 'mobile_phone', None), 138 | 'type': getattr(member, 'odata_type', None) 139 | } 140 | formatted_members.append(member_data) 141 | return formatted_members 142 | except Exception as e: 143 | logger.error(f"Error fetching group members for group {group_id}: {str(e)}") 144 | raise 145 | 146 | async def create_group(graph_client: GraphClient, group_data: Dict[str, Any]) -> Dict[str, Any]: 147 | """Create a new group in Microsoft Graph. 148 | 149 | Args: 150 | graph_client: GraphClient instance 151 | group_data: Dictionary containing group properties 152 | 153 | Returns: 154 | The created group data 155 | """ 156 | try: 157 | client = graph_client.get_client() 158 | 159 | # Check if group already exists with the same display name or mail nickname 160 | display_name = group_data.get('displayName') 161 | mail_nickname = group_data.get('mailNickname') 162 | 163 | if display_name: 164 | # Check if a group with the same display name already exists 165 | filter_query = f"displayName eq '{display_name}'" 166 | query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters( 167 | filter=filter_query 168 | ) 169 | request_configuration = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(query_parameters=query_params) 170 | response = await client.groups.get(request_configuration=request_configuration) 171 | 172 | if response and response.value and len(response.value) > 0: 173 | logger.info(f"Group with display name '{display_name}' already exists") 174 | # Return the existing group 175 | existing_group = response.value[0] 176 | return { 177 | 'id': existing_group.id, 178 | 'displayName': existing_group.display_name, 179 | 'mail': existing_group.mail, 180 | 'mailNickname': existing_group.mail_nickname, 181 | 'description': existing_group.description, 182 | 'groupTypes': existing_group.group_types, 183 | 'securityEnabled': existing_group.security_enabled, 184 | 'mailEnabled': existing_group.mail_enabled, 185 | 'visibility': existing_group.visibility, 186 | 'createdDateTime': existing_group.created_date_time.isoformat() if existing_group.created_date_time else None, 187 | 'status': 'already_exists' 188 | } 189 | 190 | # Create a group object with the provided data 191 | group = Group() 192 | 193 | # Set required properties 194 | if 'displayName' in group_data: 195 | group.display_name = group_data['displayName'] 196 | else: 197 | raise ValueError("displayName is required for creating a group") 198 | 199 | if 'mailNickname' in group_data: 200 | group.mail_nickname = group_data['mailNickname'] 201 | else: 202 | raise ValueError("mailNickname is required for creating a group") 203 | 204 | # Set optional properties 205 | if 'description' in group_data: 206 | group.description = group_data['description'] 207 | 208 | # Handle group types and dynamic membership 209 | is_dynamic = False 210 | if 'groupTypes' in group_data: 211 | group_types = group_data['groupTypes'] 212 | 213 | # Check if DynamicMembership is in the group types 214 | if 'DynamicMembership' in group_types: 215 | is_dynamic = True 216 | 217 | # For dynamic groups, membershipRule and membershipRuleProcessingState are required 218 | if 'membershipRule' not in group_data: 219 | raise ValueError("membershipRule is required for dynamic membership groups") 220 | 221 | group.membership_rule = group_data['membershipRule'] 222 | group.membership_rule_processing_state = group_data.get('membershipRuleProcessingState', 'On') 223 | 224 | group.group_types = group_types 225 | 226 | if 'mailEnabled' in group_data: 227 | group.mail_enabled = group_data['mailEnabled'] 228 | 229 | if 'securityEnabled' in group_data: 230 | group.security_enabled = group_data['securityEnabled'] 231 | 232 | if 'visibility' in group_data: 233 | group.visibility = group_data['visibility'] 234 | 235 | if 'owners' in group_data: 236 | if not isinstance(group_data['owners'], list): 237 | raise ValueError("owners must be a list of user IDs") 238 | 239 | if 'members' in group_data and not is_dynamic: 240 | # Members cannot be added during creation for dynamic groups 241 | if not isinstance(group_data['members'], list): 242 | raise ValueError("members must be a list of user IDs") 243 | 244 | # Create the group 245 | new_group = await client.groups.post(group) 246 | 247 | # Add owners if provided 248 | if 'owners' in group_data and new_group and new_group.id: 249 | for owner_id in group_data['owners']: 250 | await add_group_owner(graph_client, new_group.id, owner_id) 251 | 252 | # Add members if provided and not dynamic membership 253 | if 'members' in group_data and new_group and new_group.id and not is_dynamic: 254 | for member_id in group_data['members']: 255 | await add_group_member(graph_client, new_group.id, member_id) 256 | 257 | # Return the created group 258 | if new_group: 259 | created_group = { 260 | 'id': new_group.id, 261 | 'displayName': new_group.display_name, 262 | 'mail': new_group.mail, 263 | 'mailNickname': new_group.mail_nickname, 264 | 'description': new_group.description, 265 | 'groupTypes': new_group.group_types, 266 | 'securityEnabled': new_group.security_enabled, 267 | 'mailEnabled': new_group.mail_enabled, 268 | 'visibility': new_group.visibility, 269 | 'createdDateTime': new_group.created_date_time.isoformat() if new_group.created_date_time else None 270 | } 271 | 272 | # Add dynamic membership properties if applicable 273 | if is_dynamic: 274 | created_group['membershipRule'] = new_group.membership_rule 275 | created_group['membershipRuleProcessingState'] = new_group.membership_rule_processing_state 276 | 277 | return created_group 278 | 279 | raise Exception("Failed to create group") 280 | except Exception as e: 281 | logger.error(f"Error creating group: {str(e)}") 282 | raise 283 | 284 | async def update_group(graph_client: GraphClient, group_id: str, group_data: Dict[str, Any]) -> Dict[str, Any]: 285 | """Update an existing group in Microsoft Graph. 286 | 287 | Args: 288 | graph_client: GraphClient instance 289 | group_id: ID of the group to update 290 | group_data: Dictionary containing group properties to update 291 | 292 | Returns: 293 | The updated group data 294 | """ 295 | try: 296 | client = graph_client.get_client() 297 | 298 | # Create a group object with the provided update data 299 | group = Group() 300 | 301 | # Set properties to update 302 | if 'displayName' in group_data: 303 | group.display_name = group_data['displayName'] 304 | 305 | if 'mailNickname' in group_data: 306 | group.mail_nickname = group_data['mailNickname'] 307 | 308 | if 'description' in group_data: 309 | group.description = group_data['description'] 310 | 311 | if 'visibility' in group_data: 312 | group.visibility = group_data['visibility'] 313 | 314 | # Update the group 315 | await client.groups.by_group_id(group_id).patch(group) 316 | 317 | # Get the updated group to return 318 | updated_group = await get_group_by_id(graph_client, group_id) 319 | if not updated_group: 320 | raise Exception(f"Failed to retrieve updated group with ID {group_id}") 321 | 322 | return updated_group 323 | except Exception as e: 324 | logger.error(f"Error updating group {group_id}: {str(e)}") 325 | raise 326 | 327 | async def delete_group(graph_client: GraphClient, group_id: str) -> bool: 328 | """Delete a group from Microsoft Graph. 329 | 330 | Args: 331 | graph_client: GraphClient instance 332 | group_id: ID of the group to delete 333 | 334 | Returns: 335 | True if successful, raises an exception otherwise 336 | """ 337 | try: 338 | client = graph_client.get_client() 339 | 340 | # Delete the group 341 | await client.groups.by_group_id(group_id).delete() 342 | 343 | return True 344 | except Exception as e: 345 | logger.error(f"Error deleting group {group_id}: {str(e)}") 346 | raise 347 | 348 | async def add_group_member(graph_client: GraphClient, group_id: str, member_id: str) -> bool: 349 | """Add a member to a group. 350 | 351 | Args: 352 | graph_client: GraphClient instance 353 | group_id: ID of the group 354 | member_id: ID of the member (user, group, device, etc.) to add 355 | 356 | Returns: 357 | True if successful, raises an exception otherwise 358 | """ 359 | try: 360 | client = graph_client.get_client() 361 | 362 | # First, check if the group is dynamic - can't add members to dynamic groups 363 | group = await client.groups.by_group_id(group_id).get() 364 | if group and group.group_types and 'DynamicMembership' in group.group_types: 365 | logger.warning(f"Cannot add members to dynamic group {group_id}") 366 | raise ValueError("Cannot add members to a dynamic membership group. Members are determined by the membership rule.") 367 | 368 | # Check if member is already in the group 369 | try: 370 | # This will raise an exception if member is not found 371 | existing_member = await client.groups.by_group_id(group_id).members.by_directory_object_id(member_id).get() 372 | if existing_member: 373 | logger.info(f"Member {member_id} is already in group {group_id}") 374 | return True 375 | except Exception: 376 | # Member is not in the group, continue with adding 377 | pass 378 | 379 | # Create a reference to the directory object (member) 380 | directory_object = DirectoryObject() 381 | directory_object.id = member_id 382 | 383 | # Add the member to the group 384 | await client.groups.by_group_id(group_id).members.ref.post(directory_object) 385 | 386 | return True 387 | except Exception as e: 388 | logger.error(f"Error adding member {member_id} to group {group_id}: {str(e)}") 389 | raise 390 | 391 | async def remove_group_member(graph_client: GraphClient, group_id: str, member_id: str) -> bool: 392 | """Remove a member from a group. 393 | 394 | Args: 395 | graph_client: GraphClient instance 396 | group_id: ID of the group 397 | member_id: ID of the member to remove 398 | 399 | Returns: 400 | True if successful, raises an exception otherwise 401 | """ 402 | try: 403 | client = graph_client.get_client() 404 | 405 | # First, check if the group is dynamic - can't remove members from dynamic groups 406 | group = await client.groups.by_group_id(group_id).get() 407 | if group and group.group_types and 'DynamicMembership' in group.group_types: 408 | logger.warning(f"Cannot remove members from dynamic group {group_id}") 409 | raise ValueError("Cannot remove members from a dynamic membership group. Members are determined by the membership rule.") 410 | 411 | # Check if member exists in the group 412 | try: 413 | # This will raise an exception if member is not found 414 | await client.groups.by_group_id(group_id).members.by_directory_object_id(member_id).get() 415 | except Exception as e: 416 | logger.info(f"Member {member_id} not found in group {group_id}: {str(e)}") 417 | return True # Already not a member, so removal "succeeded" 418 | 419 | # Remove the member from the group 420 | await client.groups.by_group_id(group_id).members.by_directory_object_id(member_id).ref.delete() 421 | 422 | return True 423 | except Exception as e: 424 | logger.error(f"Error removing member {member_id} from group {group_id}: {str(e)}") 425 | raise 426 | 427 | async def add_group_owner(graph_client: GraphClient, group_id: str, owner_id: str) -> bool: 428 | """Add an owner to a group. 429 | 430 | Args: 431 | graph_client: GraphClient instance 432 | group_id: ID of the group 433 | owner_id: ID of the user to add as owner 434 | 435 | Returns: 436 | True if successful, raises an exception otherwise 437 | """ 438 | try: 439 | client = graph_client.get_client() 440 | 441 | # Create a reference to the directory object (owner) 442 | directory_object = DirectoryObject() 443 | directory_object.id = owner_id 444 | 445 | # Add the owner to the group 446 | await client.groups.by_group_id(group_id).owners.ref.post(directory_object) 447 | 448 | return True 449 | except Exception as e: 450 | logger.error(f"Error adding owner {owner_id} to group {group_id}: {str(e)}") 451 | raise 452 | 453 | async def remove_group_owner(graph_client: GraphClient, group_id: str, owner_id: str) -> bool: 454 | """Remove an owner from a group. 455 | 456 | Args: 457 | graph_client: GraphClient instance 458 | group_id: ID of the group 459 | owner_id: ID of the owner to remove 460 | 461 | Returns: 462 | True if successful, raises an exception otherwise 463 | """ 464 | try: 465 | client = graph_client.get_client() 466 | 467 | # Remove the owner from the group 468 | await client.groups.by_group_id(group_id).owners.by_directory_object_id(owner_id).ref.delete() 469 | 470 | return True 471 | except Exception as e: 472 | logger.error(f"Error removing owner {owner_id} from group {group_id}: {str(e)}") 473 | raise -------------------------------------------------------------------------------- /src/msgraph_mcp_server/server.py: -------------------------------------------------------------------------------- 1 | """Microsoft Graph MCP Server. 2 | 3 | This module provides the main FastMCP server implementation for 4 | interacting with Microsoft Graph services. 5 | """ 6 | 7 | import logging 8 | from typing import Dict, List, Optional, Any 9 | from fastmcp import FastMCP, Context 10 | 11 | from auth.graph_auth import GraphAuthManager, AuthenticationError 12 | from utils.graph_client import GraphClient 13 | from utils.password_generator import generate_secure_password 14 | from resources import users, signin_logs, mfa, conditional_access, groups, managed_devices, audit_logs, password_auth, permissions_helper, applications, service_principals 15 | 16 | # Configure logging 17 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 18 | logger = logging.getLogger(__name__) 19 | 20 | # Create an MCP server 21 | mcp = FastMCP("EntraID MCP Server") 22 | 23 | # Initialize Graph client 24 | try: 25 | auth_manager = GraphAuthManager() 26 | graph_client = GraphClient(auth_manager) 27 | logger.info("Successfully initialized Graph client") 28 | except AuthenticationError as e: 29 | logger.error(f"Failed to initialize Graph client: {str(e)}") 30 | raise 31 | 32 | @mcp.tool() 33 | async def search_users(query: str, ctx: Context, limit: int = 10) -> List[Dict[str, str]]: 34 | """Search for users by name or email. 35 | 36 | Args: 37 | query: Search query (name or email) 38 | ctx: Context object 39 | limit: Maximum number of results to return (default: 10) 40 | """ 41 | await ctx.info(f"Searching for users matching '{query}'...") 42 | 43 | try: 44 | results = await users.search_users(graph_client, query, limit) 45 | await ctx.report_progress(progress=100, total=100) 46 | return results 47 | except AuthenticationError as e: 48 | error_msg = f"Authentication error: {str(e)}" 49 | logger.error(error_msg) 50 | await ctx.error(error_msg) 51 | raise 52 | except Exception as e: 53 | error_msg = f"Error searching users: {str(e)}" 54 | logger.error(error_msg) 55 | await ctx.error(error_msg) 56 | raise 57 | 58 | @mcp.tool() 59 | async def get_user_by_id(user_id: str, ctx: Context) -> Optional[Dict[str, Any]]: 60 | """Get a specific user by their ID. 61 | 62 | Args: 63 | user_id: The unique identifier (ID) of the user. 64 | ctx: Context object 65 | 66 | Returns: 67 | A dictionary containing the user's details if found, otherwise None. 68 | """ 69 | await ctx.info(f"Fetching user with ID: {user_id}...") 70 | 71 | try: 72 | result = await users.get_user_by_id(graph_client, user_id) 73 | await ctx.report_progress(progress=100, total=100) 74 | if not result: 75 | await ctx.warning(f"User with ID {user_id} not found.") 76 | return result 77 | except AuthenticationError as e: 78 | error_msg = f"Authentication error: {str(e)}" 79 | logger.error(error_msg) 80 | await ctx.error(error_msg) 81 | raise 82 | except Exception as e: 83 | error_msg = f"Error fetching user {user_id}: {str(e)}" 84 | logger.error(error_msg) 85 | await ctx.error(error_msg) 86 | raise 87 | 88 | @mcp.tool() 89 | async def get_user_sign_ins(user_id: str, ctx: Context, days: int = 7) -> List[Dict[str, Any]]: 90 | """Get sign-in logs for a specific user within the last N days. 91 | 92 | Requires AuditLog.Read.All permission. 93 | 94 | Args: 95 | user_id: The unique identifier (ID) of the user. 96 | ctx: Context object 97 | days: The number of past days to retrieve logs for (default: 7). 98 | 99 | Returns: 100 | A list of dictionaries, each representing a sign-in log event. 101 | """ 102 | await ctx.info(f"Fetching sign-in logs for user {user_id} for the last {days} days...") 103 | 104 | try: 105 | logs = await signin_logs.get_user_sign_in_logs(graph_client, user_id, days) 106 | await ctx.report_progress(progress=100, total=100) 107 | if not logs: 108 | await ctx.info(f"No sign-in logs found for user {user_id} in the last {days} days.") 109 | return logs 110 | except AuthenticationError as e: 111 | error_msg = f"Authentication error: {str(e)}" 112 | logger.error(error_msg) 113 | await ctx.error(error_msg) 114 | raise 115 | except Exception as e: 116 | error_msg = f"Error fetching sign-in logs for {user_id}: {str(e)}" 117 | # Check for permission errors specifically 118 | if "Authorization_RequestDenied" in str(e): 119 | error_msg += " (Ensure the application has AuditLog.Read.All permission)" 120 | await ctx.error(error_msg) 121 | else: 122 | await ctx.error(error_msg) 123 | logger.error(error_msg) 124 | raise 125 | 126 | @mcp.tool() 127 | async def get_user_mfa_status(user_id: str, ctx: Context) -> Optional[Dict[str, Any]]: 128 | """Get MFA status and methods for a specific user. 129 | 130 | Args: 131 | user_id: The unique identifier of the user. 132 | ctx: Context object 133 | 134 | Returns: 135 | A dictionary containing MFA status and methods information. 136 | """ 137 | await ctx.info(f"Fetching MFA status for user {user_id}...") 138 | 139 | try: 140 | result = await mfa.get_mfa_status(graph_client, user_id) 141 | await ctx.report_progress(progress=100, total=100) 142 | if not result: 143 | await ctx.warning(f"No MFA data found for user {user_id}") 144 | return result 145 | except AuthenticationError as e: 146 | error_msg = f"Authentication error: {str(e)}" 147 | logger.error(error_msg) 148 | await ctx.error(error_msg) 149 | raise 150 | except Exception as e: 151 | error_msg = f"Error fetching MFA status for {user_id}: {str(e)}" 152 | logger.error(error_msg) 153 | await ctx.error(error_msg) 154 | raise 155 | 156 | @mcp.tool() 157 | async def get_group_mfa_status(group_id: str, ctx: Context) -> List[Dict[str, Any]]: 158 | """Get MFA status for all members of a group. 159 | 160 | Args: 161 | group_id: The unique identifier of the group. 162 | ctx: Context object 163 | 164 | Returns: 165 | A list of dictionaries containing MFA status for each group member. 166 | """ 167 | await ctx.info(f"Fetching MFA status for group {group_id}...") 168 | 169 | try: 170 | results = await mfa.get_group_mfa_status(graph_client, group_id) 171 | await ctx.report_progress(progress=100, total=100) 172 | if not results: 173 | await ctx.warning(f"No MFA data found for group {group_id}") 174 | return results 175 | except AuthenticationError as e: 176 | error_msg = f"Authentication error: {str(e)}" 177 | logger.error(error_msg) 178 | await ctx.error(error_msg) 179 | raise 180 | except Exception as e: 181 | error_msg = f"Error fetching group MFA status for {group_id}: {str(e)}" 182 | logger.error(error_msg) 183 | await ctx.error(error_msg) 184 | raise 185 | 186 | @mcp.tool() 187 | async def get_privileged_users(ctx: Context) -> List[Dict[str, Any]]: 188 | """Get all users who are members of privileged directory roles.""" 189 | await ctx.info("Fetching privileged users...") 190 | try: 191 | privileged_users = await users.get_privileged_users(graph_client) 192 | await ctx.report_progress(progress=100, total=100) 193 | return privileged_users 194 | except Exception as e: 195 | await ctx.error(f"Error fetching privileged users: {str(e)}") 196 | raise 197 | 198 | @mcp.tool() 199 | async def get_conditional_access_policies(ctx: Context) -> List[Dict[str, Any]]: 200 | """Get all conditional access policies. 201 | 202 | Args: 203 | ctx: Context object 204 | 205 | Returns: 206 | A list of dictionaries, each representing a conditional access policy. 207 | """ 208 | await ctx.info("Fetching conditional access policies...") 209 | try: 210 | policies = await conditional_access.get_conditional_access_policies(graph_client) 211 | await ctx.report_progress(progress=100, total=100) 212 | return policies 213 | except Exception as e: 214 | error_msg = f"Error fetching conditional access policies: {str(e)}" 215 | logger.error(error_msg) 216 | await ctx.error(error_msg) 217 | raise 218 | 219 | @mcp.tool() 220 | async def get_conditional_access_policy_by_id(policy_id: str, ctx: Context) -> Dict[str, Any]: 221 | """Get a single conditional access policy by its ID with comprehensive details. 222 | 223 | Args: 224 | policy_id: The unique identifier (ID) of the conditional access policy. 225 | ctx: Context object 226 | 227 | Returns: 228 | A dictionary containing the policy's details if found, otherwise an empty dict. 229 | """ 230 | await ctx.info(f"Fetching conditional access policy with ID: {policy_id}...") 231 | try: 232 | result = await conditional_access.get_conditional_access_policy_by_id(graph_client, policy_id) 233 | await ctx.report_progress(progress=100, total=100) 234 | if not result: 235 | await ctx.warning(f"Policy with ID {policy_id} not found.") 236 | return result 237 | except Exception as e: 238 | error_msg = f"Error fetching conditional access policy {policy_id}: {str(e)}" 239 | logger.error(error_msg) 240 | await ctx.error(error_msg) 241 | raise 242 | 243 | @mcp.tool() 244 | async def get_all_groups(ctx: Context, limit: int = 100) -> List[Dict[str, Any]]: 245 | """Get all groups (up to the specified limit, with paging).""" 246 | await ctx.info(f"Fetching up to {limit} groups...") 247 | try: 248 | results = await groups.get_all_groups(graph_client, limit) 249 | await ctx.report_progress(progress=100, total=100) 250 | return results 251 | except Exception as e: 252 | error_msg = f"Error fetching all groups: {str(e)}" 253 | logger.error(error_msg) 254 | await ctx.error(error_msg) 255 | raise 256 | 257 | @mcp.tool() 258 | async def get_group_by_id(group_id: str, ctx: Context) -> Optional[Dict[str, Any]]: 259 | """Get a specific group by its ID.""" 260 | await ctx.info(f"Fetching group with ID: {group_id}...") 261 | try: 262 | result = await groups.get_group_by_id(graph_client, group_id) 263 | await ctx.report_progress(progress=100, total=100) 264 | if not result: 265 | await ctx.warning(f"Group with ID {group_id} not found.") 266 | return result 267 | except Exception as e: 268 | error_msg = f"Error fetching group {group_id}: {str(e)}" 269 | logger.error(error_msg) 270 | await ctx.error(error_msg) 271 | raise 272 | 273 | @mcp.tool() 274 | async def search_groups_by_name(name: str, ctx: Context, limit: int = 50) -> List[Dict[str, Any]]: 275 | """Search for groups by display name (case-insensitive, partial match, with paging).""" 276 | await ctx.info(f"Searching for groups with name matching '{name}'...") 277 | try: 278 | results = await groups.search_groups_by_name(graph_client, name, limit) 279 | await ctx.report_progress(progress=100, total=100) 280 | return results 281 | except Exception as e: 282 | error_msg = f"Error searching groups by name: {str(e)}" 283 | logger.error(error_msg) 284 | await ctx.error(error_msg) 285 | raise 286 | 287 | @mcp.tool() 288 | async def get_group_members(group_id: str, ctx: Context, limit: int = 100) -> List[Dict[str, Any]]: 289 | """Get members of a group by group ID (up to the specified limit, with paging).""" 290 | await ctx.info(f"Fetching up to {limit} members for group {group_id}...") 291 | try: 292 | results = await groups.get_group_members(graph_client, group_id, limit) 293 | await ctx.report_progress(progress=100, total=100) 294 | return results 295 | except Exception as e: 296 | error_msg = f"Error fetching group members for group {group_id}: {str(e)}" 297 | logger.error(error_msg) 298 | await ctx.error(error_msg) 299 | raise 300 | 301 | @mcp.tool() 302 | async def get_user_groups(user_id: str, ctx: Context) -> List[Dict[str, Any]]: 303 | """Get all groups (including transitive memberships) for a user by user ID.""" 304 | await ctx.info(f"Fetching all groups for user {user_id}...") 305 | try: 306 | results = await users.get_user_groups(graph_client, user_id) 307 | await ctx.report_progress(progress=100, total=100) 308 | return results 309 | except Exception as e: 310 | error_msg = f"Error fetching groups for user {user_id}: {str(e)}" 311 | logger.error(error_msg) 312 | await ctx.error(error_msg) 313 | raise 314 | 315 | @mcp.tool() 316 | async def get_user_roles(user_id: str, ctx: Context) -> List[Dict[str, Any]]: 317 | """Get all directory roles assigned to a user by user ID.""" 318 | await ctx.info(f"Fetching all directory roles for user {user_id}...") 319 | try: 320 | results = await users.get_user_roles(graph_client, user_id) 321 | await ctx.report_progress(progress=100, total=100) 322 | return results 323 | except Exception as e: 324 | error_msg = f"Error fetching roles for user {user_id}: {str(e)}" 325 | logger.error(error_msg) 326 | await ctx.error(error_msg) 327 | raise 328 | 329 | @mcp.tool() 330 | async def get_all_managed_devices(ctx: Context, filter_os: str = None) -> List[Dict[str, Any]]: 331 | """Get all managed devices (optionally filter by OS).""" 332 | await ctx.info(f"Fetching all managed devices{f' with OS {filter_os}' if filter_os else ''}...") 333 | try: 334 | results = await managed_devices.get_all_managed_devices(graph_client, filter_os) 335 | await ctx.report_progress(progress=100, total=100) 336 | return results 337 | except Exception as e: 338 | error_msg = f"Error fetching all managed devices: {str(e)}" 339 | logger.error(error_msg) 340 | await ctx.error(error_msg) 341 | raise 342 | 343 | @mcp.tool() 344 | async def get_managed_devices_by_user(user_id: str, ctx: Context) -> List[Dict[str, Any]]: 345 | """Get all managed devices for a specific userId.""" 346 | await ctx.info(f"Fetching managed devices for user {user_id}...") 347 | try: 348 | results = await managed_devices.get_managed_devices_by_user(graph_client, user_id) 349 | await ctx.report_progress(progress=100, total=100) 350 | return results 351 | except Exception as e: 352 | error_msg = f"Error fetching managed devices for user {user_id}: {str(e)}" 353 | logger.error(error_msg) 354 | await ctx.error(error_msg) 355 | raise 356 | 357 | @mcp.tool() 358 | async def get_user_audit_logs(user_id: str, ctx: Context, days: int = 30) -> List[Dict[str, Any]]: 359 | """Get all relevant directory audit logs for a user by user_id within the last N days (default 30).""" 360 | await ctx.info(f"Fetching directory audit logs for user {user_id} for the last {days} days...") 361 | try: 362 | results = await audit_logs.get_user_audit_logs(graph_client, user_id, days) 363 | await ctx.report_progress(progress=100, total=100) 364 | return results 365 | except Exception as e: 366 | error_msg = f"Error fetching directory audit logs for user {user_id}: {str(e)}" 367 | logger.error(error_msg) 368 | await ctx.error(error_msg) 369 | raise 370 | 371 | @mcp.tool() 372 | async def list_user_password_methods(user_id: str, ctx: Context) -> List[Dict[str, Any]]: 373 | """List a user's password authentication methods.""" 374 | await ctx.info(f"Fetching password authentication methods for user {user_id}...") 375 | try: 376 | results = await password_auth.list_user_password_methods(graph_client, user_id) 377 | await ctx.report_progress(progress=100, total=100) 378 | return results 379 | except Exception as e: 380 | error_msg = f"Error listing password methods for user {user_id}: {str(e)}" 381 | logger.error(error_msg) 382 | await ctx.error(error_msg) 383 | raise 384 | 385 | @mcp.tool() 386 | async def get_user_password_method(user_id: str, method_id: str, ctx: Context) -> Optional[Dict[str, Any]]: 387 | """Get a specific password authentication method for a user.""" 388 | await ctx.info(f"Fetching password method {method_id} for user {user_id}...") 389 | try: 390 | result = await password_auth.get_user_password_method(graph_client, user_id, method_id) 391 | await ctx.report_progress(progress=100, total=100) 392 | if not result: 393 | await ctx.warning(f"Password method {method_id} not found for user {user_id}") 394 | return result 395 | except Exception as e: 396 | error_msg = f"Error getting password method {method_id} for user {user_id}: {str(e)}" 397 | logger.error(error_msg) 398 | await ctx.error(error_msg) 399 | raise 400 | 401 | @mcp.tool() 402 | async def reset_user_password_direct(user_id: str, ctx: Context, password: str = None, require_change_on_next_sign_in: bool = True, generate_password: bool = False, password_length: int = 12) -> Dict[str, Any]: 403 | """Reset a user's password with a specific password value. 404 | 405 | Args: 406 | user_id: The unique identifier of the user 407 | ctx: Context object 408 | password: The new password to set for the user (if None and generate_password is True, a random password will be generated) 409 | require_change_on_next_sign_in: Whether to require the user to change password on next sign-in (default: True) 410 | generate_password: Whether to generate a random secure password (default: False) 411 | password_length: Length of the generated password if generate_password is True (default: 12) 412 | 413 | Returns: 414 | A dictionary with the operation result 415 | """ 416 | await ctx.info(f"Directly resetting password for user {user_id}...") 417 | 418 | try: 419 | # Generate a secure password if requested 420 | if generate_password: 421 | password = generate_secure_password(password_length) 422 | await ctx.info(f"Generated a secure password of length {password_length}") 423 | 424 | # Ensure we have a password 425 | if not password: 426 | raise ValueError("Password must be provided or generate_password must be set to True") 427 | 428 | result = await password_auth.reset_user_password_direct(graph_client, user_id, password, require_change_on_next_sign_in) 429 | 430 | # Include the generated password in the result if we generated one 431 | if generate_password: 432 | result['generated_password'] = password 433 | 434 | await ctx.report_progress(progress=100, total=100) 435 | await ctx.info(f"Password successfully reset for user {user_id} using the direct method") 436 | return result 437 | except Exception as e: 438 | error_msg = f"Error directly resetting password for user {user_id}: {str(e)}" 439 | logger.error(error_msg) 440 | await ctx.error(error_msg) 441 | raise 442 | 443 | @mcp.tool() 444 | async def suggest_permissions_for_task(task_category: str, task_name: str, ctx: Context) -> Dict[str, Any]: 445 | """Suggest Microsoft Graph permissions for a specific task based on common mappings.""" 446 | await ctx.info(f"Suggesting permissions for task '{task_category}/{task_name}'...") 447 | try: 448 | result = await permissions_helper.suggest_permissions_for_task(task_category, task_name) 449 | await ctx.report_progress(progress=100, total=100) 450 | return result 451 | except Exception as e: 452 | error_msg = f"Error suggesting permissions for task {task_category}/{task_name}: {str(e)}" 453 | logger.error(error_msg) 454 | await ctx.error(error_msg) 455 | raise 456 | 457 | @mcp.tool() 458 | async def list_permission_categories_and_tasks(ctx: Context) -> Dict[str, Any]: 459 | """List all available categories and tasks for permission suggestions.""" 460 | await ctx.info("Listing available permission categories and tasks...") 461 | try: 462 | result = await permissions_helper.list_available_categories_and_tasks() 463 | await ctx.report_progress(progress=100, total=100) 464 | return result 465 | except Exception as e: 466 | error_msg = f"Error listing permission categories and tasks: {str(e)}" 467 | logger.error(error_msg) 468 | await ctx.error(error_msg) 469 | raise 470 | 471 | @mcp.tool() 472 | async def get_all_graph_permissions(ctx: Context) -> Dict[str, Any]: 473 | """Get all Microsoft Graph permissions directly from the Microsoft Graph API.""" 474 | await ctx.info("Retrieving all Microsoft Graph permissions...") 475 | try: 476 | result = await permissions_helper.get_all_graph_permissions(graph_client) 477 | await ctx.report_progress(progress=100, total=100) 478 | await ctx.info(f"Retrieved {len(result.get('delegated_permissions', []))} delegated and {len(result.get('application_permissions', []))} application permissions") 479 | return result 480 | except Exception as e: 481 | error_msg = f"Error retrieving Microsoft Graph permissions: {str(e)}" 482 | logger.error(error_msg) 483 | await ctx.error(error_msg) 484 | raise 485 | 486 | @mcp.tool() 487 | async def search_permissions(search_term: str, ctx: Context, permission_type: str = None) -> Dict[str, Any]: 488 | """Search for Microsoft Graph permissions by keyword.""" 489 | await ctx.info(f"Searching for permissions with term '{search_term}'...") 490 | try: 491 | result = await permissions_helper.search_permissions(graph_client, search_term, permission_type) 492 | await ctx.report_progress(progress=100, total=100) 493 | await ctx.info(f"Found {result.get('total_matches', 0)} matching permissions") 494 | return result 495 | except Exception as e: 496 | error_msg = f"Error searching for permissions with term '{search_term}': {str(e)}" 497 | logger.error(error_msg) 498 | await ctx.error(error_msg) 499 | raise 500 | 501 | @mcp.tool() 502 | async def create_group(ctx: Context, group_data: Dict[str, Any]) -> Dict[str, Any]: 503 | """Create a new group in Microsoft Graph. 504 | 505 | Args: 506 | ctx: Context object 507 | group_data: Dictionary containing group properties: 508 | - displayName: Display name of the group (required) 509 | - mailNickname: Mail alias for the group (required) 510 | - description: Description of the group (optional) 511 | - groupTypes: Array of group types e.g. ["Unified"] (optional) 512 | - mailEnabled: Whether the group is mail-enabled (optional) 513 | - securityEnabled: Whether the group is a security group (optional) 514 | - visibility: "Private" or "Public" for Microsoft 365 groups (optional) 515 | - owners: List of user IDs to add as owners (optional) 516 | - members: List of IDs to add as members (optional) 517 | - membershipRule: Rule for dynamic groups (required if DynamicMembership is in groupTypes) 518 | - membershipRuleProcessingState: "On" or "Paused" for dynamic groups (default: "On") 519 | 520 | Returns: 521 | The created group data with status field if group already exists 522 | """ 523 | await ctx.info(f"Creating group '{group_data.get('displayName', 'unnamed')}'...") 524 | 525 | try: 526 | # Validate required fields 527 | if not group_data.get('displayName'): 528 | raise ValueError("displayName is required for creating a group") 529 | 530 | if not group_data.get('mailNickname'): 531 | raise ValueError("mailNickname is required for creating a group") 532 | 533 | # Check if this is a dynamic membership group 534 | group_types = group_data.get('groupTypes', []) 535 | is_dynamic = 'DynamicMembership' in group_types 536 | 537 | # Validate dynamic group requirements 538 | if is_dynamic: 539 | if not group_data.get('membershipRule'): 540 | raise ValueError("membershipRule is required for dynamic membership groups") 541 | 542 | await ctx.info("Creating dynamic membership group with rule: " + group_data.get('membershipRule', '')) 543 | 544 | result = await groups.create_group(graph_client, group_data) 545 | await ctx.report_progress(progress=100, total=100) 546 | 547 | # Check if the group already existed 548 | if result.get('status') == 'already_exists': 549 | await ctx.info(f"Group with display name '{result.get('displayName')}' already exists (ID: {result.get('id')})") 550 | else: 551 | await ctx.info(f"Successfully created group with ID: {result.get('id')}") 552 | 553 | # For dynamic groups, inform about membership management 554 | if is_dynamic: 555 | await ctx.info("Created dynamic membership group. Members are managed automatically based on the membership rule.") 556 | 557 | return result 558 | except Exception as e: 559 | error_msg = f"Error creating group: {str(e)}" 560 | logger.error(error_msg) 561 | await ctx.error(error_msg) 562 | raise 563 | 564 | @mcp.tool() 565 | async def update_group(group_id: str, ctx: Context, group_data: Dict[str, Any]) -> Dict[str, Any]: 566 | """Update an existing group in Microsoft Graph. 567 | 568 | Args: 569 | group_id: ID of the group to update 570 | ctx: Context object 571 | group_data: Dictionary containing group properties to update: 572 | - displayName: Display name of the group (optional) 573 | - mailNickname: Mail alias for the group (optional) 574 | - description: Description of the group (optional) 575 | - visibility: "Private" or "Public" for Microsoft 365 groups (optional) 576 | 577 | Returns: 578 | The updated group data 579 | """ 580 | await ctx.info(f"Updating group {group_id}...") 581 | 582 | try: 583 | result = await groups.update_group(graph_client, group_id, group_data) 584 | await ctx.report_progress(progress=100, total=100) 585 | await ctx.info(f"Successfully updated group {group_id}") 586 | return result 587 | except Exception as e: 588 | error_msg = f"Error updating group {group_id}: {str(e)}" 589 | logger.error(error_msg) 590 | await ctx.error(error_msg) 591 | raise 592 | 593 | @mcp.tool() 594 | async def delete_group(group_id: str, ctx: Context) -> Dict[str, Any]: 595 | """Delete a group from Microsoft Graph. 596 | 597 | Args: 598 | group_id: ID of the group to delete 599 | ctx: Context object 600 | 601 | Returns: 602 | A dictionary with the operation result 603 | """ 604 | await ctx.info(f"Deleting group {group_id}...") 605 | 606 | try: 607 | await groups.delete_group(graph_client, group_id) 608 | await ctx.report_progress(progress=100, total=100) 609 | await ctx.info(f"Successfully deleted group {group_id}") 610 | return {"status": "success", "message": f"Group {group_id} was deleted successfully"} 611 | except Exception as e: 612 | error_msg = f"Error deleting group {group_id}: {str(e)}" 613 | logger.error(error_msg) 614 | await ctx.error(error_msg) 615 | raise 616 | 617 | @mcp.tool() 618 | async def add_group_member(group_id: str, member_id: str, ctx: Context) -> Dict[str, Any]: 619 | """Add a member to a group. 620 | 621 | Args: 622 | group_id: ID of the group 623 | member_id: ID of the member (user, group, device, etc.) to add 624 | ctx: Context object 625 | 626 | Returns: 627 | A dictionary with the operation result 628 | """ 629 | await ctx.info(f"Adding member {member_id} to group {group_id}...") 630 | 631 | try: 632 | # Try to get the group first to verify if it's a dynamic group 633 | group = await groups.get_group_by_id(graph_client, group_id) 634 | if not group: 635 | raise ValueError(f"Group with ID {group_id} not found") 636 | 637 | # Check if this is a dynamic membership group 638 | if group.get('groupTypes') and 'DynamicMembership' in group.get('groupTypes'): 639 | error_msg = "Cannot add members to a dynamic membership group. Members are determined by the membership rule." 640 | await ctx.warning(error_msg) 641 | return { 642 | "status": "error", 643 | "message": error_msg, 644 | "groupId": group_id, 645 | "memberId": member_id, 646 | "isDynamicGroup": True 647 | } 648 | 649 | # Try to add the member 650 | await groups.add_group_member(graph_client, group_id, member_id) 651 | await ctx.report_progress(progress=100, total=100) 652 | await ctx.info(f"Successfully added member {member_id} to group {group_id}") 653 | return {"status": "success", "message": f"Member {member_id} was added to group {group_id}"} 654 | except ValueError as e: 655 | # Handle case where member is already in group 656 | if "already in group" in str(e).lower(): 657 | message = f"Member {member_id} is already in group {group_id}" 658 | await ctx.info(message) 659 | return {"status": "already_exists", "message": message} 660 | # Otherwise re-raise 661 | logger.error(f"Value error adding member {member_id} to group {group_id}: {str(e)}") 662 | await ctx.error(str(e)) 663 | raise 664 | except Exception as e: 665 | error_msg = f"Error adding member {member_id} to group {group_id}: {str(e)}" 666 | logger.error(error_msg) 667 | await ctx.error(error_msg) 668 | raise 669 | 670 | @mcp.tool() 671 | async def remove_group_member(group_id: str, member_id: str, ctx: Context) -> Dict[str, Any]: 672 | """Remove a member from a group. 673 | 674 | Args: 675 | group_id: ID of the group 676 | member_id: ID of the member to remove 677 | ctx: Context object 678 | 679 | Returns: 680 | A dictionary with the operation result 681 | """ 682 | await ctx.info(f"Removing member {member_id} from group {group_id}...") 683 | 684 | try: 685 | # Try to get the group first to verify if it's a dynamic group 686 | group = await groups.get_group_by_id(graph_client, group_id) 687 | if not group: 688 | raise ValueError(f"Group with ID {group_id} not found") 689 | 690 | # Check if this is a dynamic membership group 691 | if group.get('groupTypes') and 'DynamicMembership' in group.get('groupTypes'): 692 | error_msg = "Cannot remove members from a dynamic membership group. Members are determined by the membership rule." 693 | await ctx.warning(error_msg) 694 | return { 695 | "status": "error", 696 | "message": error_msg, 697 | "groupId": group_id, 698 | "memberId": member_id, 699 | "isDynamicGroup": True 700 | } 701 | 702 | # Try to remove the member 703 | result = await groups.remove_group_member(graph_client, group_id, member_id) 704 | await ctx.report_progress(progress=100, total=100) 705 | 706 | # If we reach here, it was successful (either removed or wasn't a member) 707 | await ctx.info(f"Successfully removed member {member_id} from group {group_id}") 708 | return {"status": "success", "message": f"Member {member_id} was removed from group {group_id}"} 709 | except ValueError as e: 710 | # Handle case where member is not in group 711 | if "not found in group" in str(e).lower(): 712 | message = f"Member {member_id} is not in group {group_id}" 713 | await ctx.info(message) 714 | return {"status": "not_found", "message": message} 715 | # Otherwise re-raise 716 | logger.error(f"Value error removing member {member_id} from group {group_id}: {str(e)}") 717 | await ctx.error(str(e)) 718 | raise 719 | except Exception as e: 720 | error_msg = f"Error removing member {member_id} from group {group_id}: {str(e)}" 721 | logger.error(error_msg) 722 | await ctx.error(error_msg) 723 | raise 724 | 725 | @mcp.tool() 726 | async def add_group_owner(group_id: str, owner_id: str, ctx: Context) -> Dict[str, Any]: 727 | """Add an owner to a group. 728 | 729 | Args: 730 | group_id: ID of the group 731 | owner_id: ID of the user to add as owner 732 | ctx: Context object 733 | 734 | Returns: 735 | A dictionary with the operation result 736 | """ 737 | await ctx.info(f"Adding owner {owner_id} to group {group_id}...") 738 | 739 | try: 740 | await groups.add_group_owner(graph_client, group_id, owner_id) 741 | await ctx.report_progress(progress=100, total=100) 742 | await ctx.info(f"Successfully added owner {owner_id} to group {group_id}") 743 | return {"status": "success", "message": f"Owner {owner_id} was added to group {group_id}"} 744 | except Exception as e: 745 | error_msg = f"Error adding owner {owner_id} to group {group_id}: {str(e)}" 746 | logger.error(error_msg) 747 | await ctx.error(error_msg) 748 | raise 749 | 750 | @mcp.tool() 751 | async def remove_group_owner(group_id: str, owner_id: str, ctx: Context) -> Dict[str, Any]: 752 | """Remove an owner from a group. 753 | 754 | Args: 755 | group_id: ID of the group 756 | owner_id: ID of the owner to remove 757 | ctx: Context object 758 | 759 | Returns: 760 | A dictionary with the operation result 761 | """ 762 | await ctx.info(f"Removing owner {owner_id} from group {group_id}...") 763 | 764 | try: 765 | await groups.remove_group_owner(graph_client, group_id, owner_id) 766 | await ctx.report_progress(progress=100, total=100) 767 | await ctx.info(f"Successfully removed owner {owner_id} from group {group_id}") 768 | return {"status": "success", "message": f"Owner {owner_id} was removed from group {group_id}"} 769 | except Exception as e: 770 | error_msg = f"Error removing owner {owner_id} from group {group_id}: {str(e)}" 771 | logger.error(error_msg) 772 | await ctx.error(error_msg) 773 | raise 774 | 775 | @mcp.tool() 776 | async def list_applications(ctx: Context, limit: int = 100) -> List[Dict[str, Any]]: 777 | """List all applications (app registrations) in the tenant, with paging.""" 778 | await ctx.info(f"Listing up to {limit} applications...") 779 | try: 780 | results = await applications.list_applications(graph_client, limit) 781 | await ctx.report_progress(progress=100, total=100) 782 | return results 783 | except Exception as e: 784 | error_msg = f"Error listing applications: {str(e)}" 785 | logger.error(error_msg) 786 | await ctx.error(error_msg) 787 | raise 788 | 789 | @mcp.tool() 790 | async def get_application_by_id(app_id: str, ctx: Context) -> Optional[Dict[str, Any]]: 791 | """Get a specific application by its object ID.""" 792 | await ctx.info(f"Fetching application with ID: {app_id}...") 793 | try: 794 | result = await applications.get_application_by_id(graph_client, app_id) 795 | await ctx.report_progress(progress=100, total=100) 796 | if not result: 797 | await ctx.warning(f"Application with ID {app_id} not found.") 798 | return result 799 | except Exception as e: 800 | error_msg = f"Error fetching application {app_id}: {str(e)}" 801 | logger.error(error_msg) 802 | await ctx.error(error_msg) 803 | raise 804 | 805 | @mcp.tool() 806 | async def create_application(ctx: Context, app_data: Dict[str, Any]) -> Dict[str, Any]: 807 | """Create a new application (app registration).""" 808 | await ctx.info(f"Creating application '{app_data.get('displayName', 'unnamed')}'...") 809 | try: 810 | result = await applications.create_application(graph_client, app_data) 811 | await ctx.report_progress(progress=100, total=100) 812 | await ctx.info(f"Successfully created application with ID: {result.get('id')}") 813 | return result 814 | except Exception as e: 815 | error_msg = f"Error creating application: {str(e)}" 816 | logger.error(error_msg) 817 | await ctx.error(error_msg) 818 | raise 819 | 820 | @mcp.tool() 821 | async def update_application(app_id: str, ctx: Context, app_data: Dict[str, Any]) -> Dict[str, Any]: 822 | """Update an existing application (app registration).""" 823 | await ctx.info(f"Updating application {app_id}...") 824 | try: 825 | result = await applications.update_application(graph_client, app_id, app_data) 826 | await ctx.report_progress(progress=100, total=100) 827 | await ctx.info(f"Successfully updated application {app_id}") 828 | return result 829 | except Exception as e: 830 | error_msg = f"Error updating application {app_id}: {str(e)}" 831 | logger.error(error_msg) 832 | await ctx.error(error_msg) 833 | raise 834 | 835 | @mcp.tool() 836 | async def delete_application(app_id: str, ctx: Context) -> Dict[str, Any]: 837 | """Delete an application (app registration) by its object ID.""" 838 | await ctx.info(f"Deleting application {app_id}...") 839 | try: 840 | await applications.delete_application(graph_client, app_id) 841 | await ctx.report_progress(progress=100, total=100) 842 | await ctx.info(f"Successfully deleted application {app_id}") 843 | return {"status": "success", "message": f"Application {app_id} was deleted successfully"} 844 | except Exception as e: 845 | error_msg = f"Error deleting application {app_id}: {str(e)}" 846 | logger.error(error_msg) 847 | await ctx.error(error_msg) 848 | raise 849 | 850 | @mcp.tool() 851 | async def list_service_principals(ctx: Context, limit: int = 100) -> List[Dict[str, Any]]: 852 | """List all service principals in the tenant, with paging.""" 853 | await ctx.info(f"Listing up to {limit} service principals...") 854 | try: 855 | results = await service_principals.list_service_principals(graph_client, limit) 856 | await ctx.report_progress(progress=100, total=100) 857 | return results 858 | except Exception as e: 859 | error_msg = f"Error listing service principals: {str(e)}" 860 | logger.error(error_msg) 861 | await ctx.error(error_msg) 862 | raise 863 | 864 | @mcp.tool() 865 | async def get_service_principal_by_id(sp_id: str, ctx: Context) -> Optional[Dict[str, Any]]: 866 | """Get a specific service principal by its object ID.""" 867 | await ctx.info(f"Fetching service principal with ID: {sp_id}...") 868 | try: 869 | result = await service_principals.get_service_principal_by_id(graph_client, sp_id) 870 | await ctx.report_progress(progress=100, total=100) 871 | if not result: 872 | await ctx.warning(f"Service principal with ID {sp_id} not found.") 873 | return result 874 | except Exception as e: 875 | error_msg = f"Error fetching service principal {sp_id}: {str(e)}" 876 | logger.error(error_msg) 877 | await ctx.error(error_msg) 878 | raise 879 | 880 | @mcp.tool() 881 | async def create_service_principal(ctx: Context, sp_data: Dict[str, Any]) -> Dict[str, Any]: 882 | """Create a new service principal.""" 883 | await ctx.info(f"Creating service principal for appId '{sp_data.get('appId', 'unknown')}'...") 884 | try: 885 | result = await service_principals.create_service_principal(graph_client, sp_data) 886 | await ctx.report_progress(progress=100, total=100) 887 | await ctx.info(f"Successfully created service principal with ID: {result.get('id')}") 888 | return result 889 | except Exception as e: 890 | error_msg = f"Error creating service principal: {str(e)}" 891 | logger.error(error_msg) 892 | await ctx.error(error_msg) 893 | raise 894 | 895 | @mcp.tool() 896 | async def update_service_principal(sp_id: str, ctx: Context, sp_data: Dict[str, Any]) -> Dict[str, Any]: 897 | """Update an existing service principal.""" 898 | await ctx.info(f"Updating service principal {sp_id}...") 899 | try: 900 | result = await service_principals.update_service_principal(graph_client, sp_id, sp_data) 901 | await ctx.report_progress(progress=100, total=100) 902 | await ctx.info(f"Successfully updated service principal {sp_id}") 903 | return result 904 | except Exception as e: 905 | error_msg = f"Error updating service principal {sp_id}: {str(e)}" 906 | logger.error(error_msg) 907 | await ctx.error(error_msg) 908 | raise 909 | 910 | @mcp.tool() 911 | async def delete_service_principal(sp_id: str, ctx: Context) -> Dict[str, Any]: 912 | """Delete a service principal by its object ID.""" 913 | await ctx.info(f"Deleting service principal {sp_id}...") 914 | try: 915 | await service_principals.delete_service_principal(graph_client, sp_id) 916 | await ctx.report_progress(progress=100, total=100) 917 | await ctx.info(f"Successfully deleted service principal {sp_id}") 918 | return {"status": "success", "message": f"Service principal {sp_id} was deleted successfully"} 919 | except Exception as e: 920 | error_msg = f"Error deleting service principal {sp_id}: {str(e)}" 921 | logger.error(error_msg) 922 | await ctx.error(error_msg) 923 | raise 924 | 925 | # Add a dynamic greeting resource 926 | @mcp.resource("greeting://{name}") 927 | def get_greeting(name: str) -> str: 928 | """Get a personalized greeting""" 929 | return f"Hello, {name}!" --------------------------------------------------------------------------------