├── .cursor ├── mcp.json └── rules │ └── securitycopilotdev.mdc ├── .gitignore ├── Diagram.png ├── LICENSE ├── README.md ├── SecurityCopilotClient.py ├── SentinelClient.py ├── requirements.txt ├── screenshot.png ├── screenshot2.png └── server.py /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "SecurityCopilot-MCP-Server1.0": { 4 | "url": "http://localhost:8000/sse" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.cursor/rules/securitycopilotdev.mdc: -------------------------------------------------------------------------------- 1 | # Security Copilot and Sentinel Integration Guidelines 2 | 3 | This project is focused on developting and testing components for Microsoft Security Copilot. 4 | 5 | When working with Microsoft Security Copilot functionality: 6 | 7 | 1. The terms "plugin," "skill," and "skillset" all refer to Microsoft Security Copilot components. 8 | 9 | 2. In the Security Copilot architecture, "skillset" and "plugin" are equivalent terms. 10 | 11 | 3. For skill testing: 12 | - Identify Sentinel KQL skills looking at the SkillSet Format and Target fields. 13 | - To test Sentinel KQL skills use the appropriate Sentinel KQL query MCP tool. Show the results of the KQL execution after running the tool. 14 | - If required input parameters are missing, please request them to the user. Only parameters inside Ipunts sections are needed, the ones in Settings are already set inside the platform. For the input parameters values always use String type. 15 | - If asked to test a skill inside Security Copilot run a prompt in for the selected skill using the run prompt skill. Show the results of the prompt to allow user validation. Considering reformating the output to enhance user experience. 16 | - IF asked to run a full test and deploy a particular skill this is the process you need to follow: 17 | 1. Extract the KQL query from the Yaml File 18 | 2. Run the KQL query in Sentinel with the appropriate Sentinel KQL query MCP tool. Display a summary of the results. 19 | 3. Validate that the results match with the description of the Skill and the intent of the KQL query. 20 | 4. If the previous validation is successful, Upload or Update the Skill in Security Copilot following the intruction below. 21 | 5. Run a Prompt to test the Skill inside Security Copilot. 22 | 6. Create a summary of the resting , the validation and the results. I need to understand if the SKill does what it is suposed to do , if the deployment was sucessfull and if the prompt results align with the initial test. 23 | 24 | 4. To deploy, update, or upload a skill/skillset/plugin: 25 | - Use the appropriate plugin Upload tool 26 | - Include the complete YAML file content as input for the deployment process 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | -------------------------------------------------------------------------------- /Diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jguimera/SecurityCopilotMCPServer/60a8de88234fd4608368ad687e6422723656ba2a/Diagram.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jaime Guimera Coll 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Security Copilot and Sentinel MCP Server 2 | 3 | A Python-based MCP server using FastMCP library that provides integration with Microsoft Security Copilot and Microsoft Sentinel using Azure Identity Authentication. 4 | ![Diagram](Diagram.png) 5 | ## Overview 6 | 7 | This project implements an MCP server that enables: 8 | 9 | - Running KQL queries against Microsoft Sentinel 10 | - Uploading/Updating Microsoft Security Copilot skillsets/plugins 11 | - Running prompts and skills in Microsoft Security Copilot 12 | 13 | The server acts as a bridge between development environments and Microsoft Security Copilot, allowing for testing, deployment, and execution of skills and plugins. It uses SSE as transport layer for the MCP server. 14 | There are many use cases for the current integration. One of the most interesting ones is to support the development, test and deployment of Security Copilot KQL Skills. 15 | 16 | ![AgentFlow1](screenshot.png) 17 | ![AgentFlow2](screenshot2.png) 18 | ## Features 19 | 20 | - **Sentinel Integration**: Execute KQL queries against your Sentinel workspace 21 | - **Security Copilot Management**: 22 | - List existing skillsets/plugins 23 | - Upload new or update existing skillsets/plugins 24 | - Run prompts or skills within Security Copilot 25 | - **Authentication Support**: Multiple authentication methods including interactive browser, client secret, and managed identity 26 | ## Roadmap 27 | The next features will include: 28 | - **Promptbook test and Update** 29 | - **Run Advance Hunting queries in Defender XDR** 30 | ## Prerequisites 31 | 32 | - Python 3.8+ 33 | - Microsoft Sentinel workspace 34 | - Microsoft Security Copilot access 35 | - Appropriate Azure permissions for Sentinel and Security Copilot 36 | 37 | ## Installation 38 | 39 | 1. Clone the repository: 40 | ``` 41 | git clone https://github.com/jguimera/SecurityCopilotMCPServer.git 42 | cd SecurityCopilotMCPServer 43 | ``` 44 | 45 | 2. Install dependencies: 46 | ``` 47 | pip install -r requirements.txt 48 | ``` 49 | 50 | 3. Create a `.env` file with the following configuration: 51 | ``` 52 | #Add App Reg to use ClientID and Secret authentication 53 | #AZURE_TENANT_ID=your_tenant_id 54 | #AZURE_CLIENT_ID=your_client_id 55 | #AZURE_CLIENT_SECRET=your_client_secret 56 | SENTINEL_SUBSCRIPTION_ID=your_subscription_id 57 | SENTINEL_RESOURCE_GROUP=your_resource_group 58 | SENTINEL_WORKSPACE_NAME=your_workspace_name 59 | SENTINEL_WORKSPACE_ID=your_workspace_id 60 | #Authentication Options: interactive, client_secret 61 | AUTHENTICATION_TYPE=interactive 62 | ``` 63 | 64 | ## Usage 65 | 66 | ### Starting the Server 67 | 68 | Run the MCP server: 69 | 70 | ``` 71 | python server.py 72 | ``` 73 | 74 | To run tests before starting the server: 75 | 76 | ``` 77 | python server.py --run-tests 78 | ``` 79 | 80 | ### Available Tools 81 | 82 | The MCP server provides the following tools: 83 | 84 | 1. **run_sentinel_query**: Execute KQL queries in Sentinel 85 | 2. **get_skillsets**: List skillsets in Security Copilot 86 | 3. **upload_plugin**: Upload or update a skillset/plugin 87 | 4. **run_prompt**: Run a prompt or skill in Security Copilot 88 | 89 | ### MCP Client Config for Cursor 90 | You can use this MCP server from the Client of your choice. In this repo you can find intructions and config files for Cursor. 91 | 92 | Add the .cursor folder inside your client project to enable the MCP tools. 93 | This folder contains two files: 94 | 1. Cursor Project Rules (securitycopilotdev.mdc): This file include some Custom Cursor Rules to help the agents in the process definition and understanding user prompts. 95 | 2. MCP Client Configuration (mcp.json): File that connects Cursor to the MCP server. 96 | 97 | You can invoke the tool directly using /tool_name parameter1="Value of the tool parameter" 98 | For example: /run_prompt content="List the most recent risky users" 99 | 100 | More info: https://docs.cursor.com/context/model-context-protocol#configuring-mcp-servers 101 | ## Contributing 102 | 103 | Contributions are welcome! Please feel free to submit a Pull Request. 104 | 105 | 1. Fork the repository 106 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 107 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 108 | 4. Push to the branch (`git push origin feature/amazing-feature`) 109 | 5. Open a Pull Request 110 | 111 | ## License 112 | 113 | This project is licensed under the MIT License - see the LICENSE file for details. 114 | -------------------------------------------------------------------------------- /SecurityCopilotClient.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from azure.identity import InteractiveBrowserCredential, ClientSecretCredential, DefaultAzureCredential 4 | import yaml 5 | import logging 6 | 7 | # Get logger 8 | logger = logging.getLogger('SecurityCopilotMCP') 9 | 10 | class SecurityCopilotClient: 11 | def __init__(self, credential): 12 | """ 13 | Initialize the Security Copilot client with Azure authentication. 14 | 15 | Args: 16 | auth_type (str): Authentication type - "interactive", "client_secret", or "default" 17 | """ 18 | self.credential = credential 19 | self.base_url = os.getenv('SECURITY_COPILOT_API_URL', 'https://api.securitycopilot.microsoft.com') 20 | self.region = os.getenv('SECURITY_COPILOT_REGION', 'eastus') 21 | self.headers = { 22 | "Content-Type": "application/json", 23 | "Accept": "application/json" 24 | } 25 | logger.info(f"SecurityCopilotClient initialized with base_url={self.base_url}, region={self.region}") 26 | 27 | def _get_authenticated_headers(self, content_type="application/json"): 28 | """ 29 | Get headers with authentication token. 30 | 31 | Args: 32 | content_type (str): Content type for the headers 33 | 34 | Returns: 35 | dict: Headers with authentication token 36 | """ 37 | if not self.credential: 38 | logger.error("Authentication required but no credential provided") 39 | raise Exception("Authentication required") 40 | 41 | try: 42 | logger.debug("Getting authentication token") 43 | self.token = self.credential.get_token("https://api.securitycopilot.microsoft.com/.default") 44 | headers = { 45 | "Content-Type": content_type, 46 | "Accept": "application/json", 47 | "Authorization": f"Bearer {self.token.token}" 48 | } 49 | logger.debug("Authentication token obtained successfully") 50 | return headers 51 | except Exception as e: 52 | logger.error(f"Failed to refresh token: {str(e)}") 53 | raise Exception(f"Failed to refresh token: {str(e)}") 54 | 55 | def get_skillsets(self, filter_name=None,full_response=True): 56 | """ 57 | Get all skillsets with their related skills from Security Copilot. 58 | 59 | Args: 60 | filter_name (str, optional): Filter skillsets by name (contains filter) 61 | full_response (bool, optional): Whether to return the full response from Security Copilot 62 | Returns: 63 | dict: All skillsets with their related skills 64 | """ 65 | # Get authenticated headers 66 | headers = self._get_authenticated_headers() 67 | 68 | # Get all skillsets 69 | skillsets_url = f"{self.base_url}/geo/{self.region}/skillsets" 70 | response = requests.get(skillsets_url, headers=headers) 71 | response.raise_for_status() 72 | 73 | skillsets_data = response.json() 74 | filtered_skillsets = [] 75 | for skillset in skillsets_data.get('value', []): 76 | if filter_name and filter_name.lower() not in skillset['name'].lower(): 77 | continue 78 | filtered_skillsets.append(skillset) 79 | 80 | if full_response: 81 | # For each skillset, get its skills 82 | skillsets_with_skills = [] 83 | for skillset in filtered_skillsets: 84 | # Apply filter if provided 85 | 86 | 87 | # Get skills for this skillset 88 | skills_url = f"{self.base_url}/geo/{self.region}/skillsets/{skillset['name']}/skills" 89 | skills_response = requests.get(skills_url, headers=headers) 90 | 91 | if skills_response.status_code == 200: 92 | skills_data = skills_response.json() 93 | # Add skills to skillset 94 | skillset['skills'] = skills_data.get('value', []) 95 | else: 96 | # If skills can't be retrieved, add empty list 97 | skillset['skills'] = [] 98 | 99 | skillsets_with_skills.append(skillset) 100 | 101 | return { 102 | "count": len(skillsets_with_skills), 103 | "skillsets": skillsets_with_skills 104 | } 105 | else: 106 | return { 107 | "count": len(skillsets_data.get('value', [])), 108 | "skillsets": skillsets_data.get('value', []) 109 | } 110 | 111 | def upload_skillset(self, yaml_content, create_if_not_exists=True): 112 | """ 113 | Upload or update a skillset in Security Copilot. 114 | 115 | Args: 116 | yaml_content (str): The YAML content of the skillset to upload 117 | create_if_not_exists (bool): Whether to create the skillset if it doesn't exist 118 | 119 | Returns: 120 | dict: The response from Security Copilot 121 | """ 122 | # Parse YAML to extract plugin name 123 | import yaml 124 | try: 125 | plugin_object = yaml.safe_load(yaml_content) 126 | plugin_name = plugin_object['Descriptor']['Name'] 127 | except Exception as e: 128 | raise Exception(f"Failed to parse YAML content: {str(e)}") 129 | 130 | if not plugin_name: 131 | raise Exception("Plugin name not found in YAML content") 132 | 133 | # Get authenticated headers 134 | headers = self._get_authenticated_headers() 135 | 136 | # Check if plugin exists 137 | skillsets_url = f"{self.base_url}/geo/{self.region}/skillsets" 138 | response = requests.get(skillsets_url, headers=headers) 139 | response.raise_for_status() 140 | 141 | existing_plugins = response.json().get('value', []) 142 | plugin_exists = any(plugin['name'] == plugin_name for plugin in existing_plugins) 143 | 144 | query_params = "?scope=Tenant&skillsetFormat=SkillsetYaml" 145 | 146 | # Set the proper content type for YAML 147 | yaml_headers = self._get_authenticated_headers("application/yaml") 148 | 149 | if plugin_exists: 150 | # Update existing plugin 151 | update_url = f"{skillsets_url}/{plugin_name}{query_params}" 152 | response = requests.put(update_url, data=yaml_content, headers=yaml_headers) 153 | response.raise_for_status() 154 | return {"status": "updated", "name": plugin_name, "response": response.json()} 155 | elif create_if_not_exists: 156 | # Create new plugin 157 | create_url = f"{skillsets_url}{query_params}" 158 | response = requests.post(create_url, data=yaml_content, headers=yaml_headers) 159 | response.raise_for_status() 160 | return {"status": "created", "name": plugin_name, "response": response.json()} 161 | else: 162 | return {"status": "not_found", "name": plugin_name} 163 | 164 | def create_new_session(self, session_name="New Security Copilot session"): 165 | """ 166 | Create a new session in Security Copilot. 167 | 168 | Args: 169 | session_name (str): The name of the session to create 170 | 171 | Returns: 172 | dict: The response from Security Copilot 173 | """ 174 | # Get authenticated headers 175 | headers = self._get_authenticated_headers() 176 | 177 | # Create session payload 178 | payload = { 179 | "name": session_name 180 | } 181 | 182 | # Create new session 183 | sessions_url = f"{self.base_url}/sessions" 184 | response = requests.post(sessions_url, json=payload, headers=headers) 185 | response.raise_for_status() 186 | 187 | return response.json() 188 | 189 | def create_prompt(self, session_id, prompt_type="Prompt", content=None, skill_name=None, inputs=None): 190 | """ 191 | Create a prompt in a Security Copilot session. 192 | 193 | Args: 194 | session_id (str): The ID of the session 195 | prompt_type (str): The type of prompt - "Prompt" or "Skill" 196 | content (str, optional): The content of the prompt (required if prompt_type is "Prompt") 197 | skill_name (str, optional): The name of the skill (required if prompt_type is "Skill") 198 | inputs (dict, optional): The inputs for the skill (required if prompt_type is "Skill") 199 | 200 | Returns: 201 | dict: The response from Security Copilot containing the promptId 202 | """ 203 | logger.info(f"Creating prompt with type: {prompt_type}") 204 | logger.debug(f"Session ID: {session_id}") 205 | 206 | # Get authenticated headers 207 | headers = self._get_authenticated_headers() 208 | 209 | # Validate parameters 210 | if prompt_type not in ["Prompt", "Skill"]: 211 | logger.error(f"Invalid prompt_type: {prompt_type}") 212 | raise ValueError("prompt_type must be either 'Prompt' or 'Skill'") 213 | 214 | if prompt_type == "Prompt" and not content: 215 | logger.error("Content is required for prompt_type 'Prompt' but not provided") 216 | raise ValueError("content is required for prompt_type 'Prompt'") 217 | 218 | if prompt_type == "Skill" and not skill_name: 219 | logger.error("skill_name is required for prompt_type 'Skill' but not provided") 220 | raise ValueError("skill_name is required for prompt_type 'Skill'") 221 | 222 | # Create payload 223 | payload = { 224 | "PromptType": prompt_type 225 | } 226 | 227 | if prompt_type == "Prompt": 228 | payload["Content"] = content 229 | logger.debug(f"Prompt content length: {len(content) if content else 0}") 230 | else: # Skill 231 | payload["SkillName"] = skill_name 232 | payload["Inputs"] = inputs or {} 233 | logger.debug(f"Using Skill: {skill_name}") 234 | logger.debug(f"Skill inputs: {inputs}") 235 | 236 | logger.debug(f"Request payload: {payload}") 237 | 238 | # Create prompt 239 | prompts_url = f"{self.base_url}/sessions/{session_id}/prompts" 240 | logger.debug(f"POST request to: {prompts_url}") 241 | 242 | try: 243 | response = requests.post(prompts_url, json=payload, headers=headers) 244 | if response.status_code >= 400: 245 | logger.error(f"Error creating prompt: {response.status_code} - {response.text}") 246 | response.raise_for_status() 247 | response_data = response.json() 248 | logger.info(f"Prompt created successfully with ID: {response_data.get('promptId')}") 249 | #logger.debug(f"Full response: {response_data}") 250 | return response_data 251 | except Exception as e: 252 | logger.error(f"Failed to create prompt: {str(e)}") 253 | raise 254 | 255 | return response.json() 256 | 257 | def create_evaluation(self, session_id, prompt_id): 258 | """ 259 | Create an evaluation for a prompt in a Security Copilot session. 260 | 261 | Args: 262 | session_id (str): The ID of the session 263 | prompt_id (str): The ID of the prompt to evaluate 264 | 265 | Returns: 266 | dict: The response from Security Copilot containing the evaluationId 267 | """ 268 | logger.info(f"Creating evaluation for prompt_id: {prompt_id}") 269 | 270 | # Get authenticated headers 271 | headers = self._get_authenticated_headers() 272 | 273 | # Create evaluation with empty payload 274 | payload = {} 275 | 276 | # Create evaluation 277 | evaluations_url = f"{self.base_url}/sessions/{session_id}/prompts/{prompt_id}/evaluations" 278 | logger.debug(f"POST request to: {evaluations_url}") 279 | 280 | try: 281 | response = requests.post(evaluations_url, json=payload, headers=headers) 282 | if response.status_code >= 400: 283 | logger.error(f"Error creating evaluation: {response.status_code} - {response.text}") 284 | response.raise_for_status() 285 | response_data = response.json() 286 | evaluation_id = response_data.get("evaluation", {}).get("evaluationId") 287 | logger.info(f"Evaluation created successfully with ID: {evaluation_id}") 288 | #logger.debug(f"Full response: {response_data}") 289 | return response_data 290 | except Exception as e: 291 | logger.error(f"Failed to create evaluation: {str(e)}") 292 | raise 293 | 294 | def poll_evaluation(self, session_id, prompt_id, evaluation_id, polling_interval=2, max_attempts=30): 295 | """ 296 | Poll the evaluation results until completion. 297 | 298 | Args: 299 | session_id (str): The ID of the session 300 | prompt_id (str): The ID of the prompt 301 | evaluation_id (str): The ID of the evaluation 302 | polling_interval (int): Time in seconds between polling attempts 303 | max_attempts (int): Maximum number of polling attempts 304 | 305 | Returns: 306 | dict: The completed evaluation result or the last polled state 307 | """ 308 | logger.info(f"Polling evaluation ID: {evaluation_id}") 309 | logger.debug(f"Polling interval: {polling_interval}s, max attempts: {max_attempts}") 310 | 311 | # Get authenticated headers 312 | headers = self._get_authenticated_headers() 313 | 314 | # Polling endpoint 315 | evaluation_url = f"{self.base_url}/sessions/{session_id}/prompts/{prompt_id}/evaluations/{evaluation_id}" 316 | 317 | import time 318 | 319 | # Poll for results 320 | attempts = 0 321 | while attempts < max_attempts: 322 | attempts += 1 323 | logger.debug(f"Polling attempt {attempts}/{max_attempts}") 324 | 325 | try: 326 | response = requests.get(evaluation_url, headers=headers) 327 | if response.status_code >= 400: 328 | logger.error(f"Error polling evaluation: {response.status_code} - {response.text}") 329 | response.raise_for_status() 330 | 331 | evaluation_data = response.json() 332 | state = evaluation_data.get("state") 333 | logger.debug(f"Current evaluation state: {state}") 334 | # Check if evaluation is completed 335 | if state == "Completed": 336 | logger.info(f"Evaluation completed after {attempts} polling attempts") 337 | return evaluation_data 338 | 339 | # Wait before next poll 340 | time.sleep(polling_interval) 341 | except Exception as e: 342 | logger.error(f"Error during polling: {str(e)}") 343 | # Continue polling despite errors 344 | 345 | logger.warning(f"Max polling attempts ({max_attempts}) reached without completion") 346 | # Return the last state if max attempts reached 347 | return evaluation_data 348 | 349 | def process_prompt(self, prompt_type="Prompt", content=None, skill_name=None, inputs=None, 350 | session_name="Security Copilot Session", polling_interval=2, max_attempts=30): 351 | """ 352 | Complete workflow to process a prompt and get results. 353 | 354 | This method: 355 | 1. Creates a new session 356 | 2. Creates a prompt in that session 357 | 3. Creates an evaluation for that prompt 358 | 4. Polls until the evaluation is complete 359 | 5. Returns the final result 360 | 361 | Args: 362 | prompt_type (str): The type of prompt - "Prompt" or "Skill" 363 | content (str, optional): The content of the prompt (required if prompt_type is "Prompt") 364 | skill_name (str, optional): The name of the skill (required if prompt_type is "Skill") 365 | inputs (dict, optional): The inputs for the skill (required if prompt_type is "Skill") 366 | session_name (str): The name for the new session 367 | polling_interval (int): Time in seconds between polling attempts 368 | max_attempts (int): Maximum number of polling attempts 369 | 370 | Returns: 371 | dict: The completed evaluation result 372 | """ 373 | logger.info(f"Processing {prompt_type} with session name: {session_name}") 374 | if prompt_type == "Skill": 375 | logger.info(f"Skill name: {skill_name}") 376 | logger.debug(f"Skill inputs: {inputs}") 377 | 378 | try: 379 | # 1. Create a new session 380 | logger.debug("Step 1: Creating a new session") 381 | session_response = self.create_new_session(session_name) 382 | session_id = session_response.get("sessionId") 383 | 384 | if not session_id: 385 | logger.error("Failed to create session. No sessionId in response.") 386 | logger.debug(f"Session response: {session_response}") 387 | raise Exception("Failed to create session. No sessionId in response.") 388 | 389 | logger.debug(f"Session created with ID: {session_id}") 390 | 391 | # 2. Create a prompt in the session 392 | logger.debug("Step 2: Creating a prompt in the session") 393 | prompt_response = self.create_prompt( 394 | session_id=session_id, 395 | prompt_type=prompt_type, 396 | content=content, 397 | skill_name=skill_name, 398 | inputs=inputs 399 | ) 400 | 401 | prompt_id = prompt_response.get("promptId") 402 | if not prompt_id: 403 | logger.error("Failed to create prompt. No promptId in response.") 404 | logger.debug(f"Prompt response: {prompt_response}") 405 | raise Exception("Failed to create prompt. No promptId in response.") 406 | 407 | logger.debug(f"Prompt created with ID: {prompt_id}") 408 | 409 | # 3. Create an evaluation for the prompt 410 | logger.debug("Step 3: Creating an evaluation for the prompt") 411 | evaluation_response = self.create_evaluation(session_id, prompt_id) 412 | evaluation_id = evaluation_response.get("evaluation", {}).get("evaluationId") 413 | 414 | if not evaluation_id: 415 | logger.error("Failed to create evaluation. No evaluationId in response.") 416 | logger.debug(f"Evaluation response: {evaluation_response}") 417 | raise Exception("Failed to create evaluation. No evaluationId in response.") 418 | 419 | logger.debug(f"Evaluation created with ID: {evaluation_id}") 420 | 421 | # 4. Poll until the evaluation is complete 422 | logger.debug("Step 4: Polling until the evaluation is complete") 423 | result = self.poll_evaluation( 424 | session_id=session_id, 425 | prompt_id=prompt_id, 426 | evaluation_id=evaluation_id, 427 | polling_interval=polling_interval, 428 | max_attempts=max_attempts 429 | ) 430 | 431 | logger.info("Prompt processing completed successfully") 432 | 433 | # 5. Return the final result 434 | return { 435 | "session_id": session_id, 436 | "prompt_id": prompt_id, 437 | "evaluation_id": evaluation_id, 438 | "result": result 439 | } 440 | except Exception as e: 441 | logger.error(f"Error during prompt processing: {str(e)}", exc_info=True) 442 | raise -------------------------------------------------------------------------------- /SentinelClient.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from azure.monitor.query import LogsQueryClient,LogsQueryStatus 3 | from azure.core.exceptions import HttpResponseError 4 | class SentinelClient: 5 | login_url="https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" 6 | API_url="https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{workspaceName}/providers/Microsoft.SecurityInsights/" 7 | API_query_url="https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{workspaceName}/providers/Microsoft.Insights/" 8 | API_management_url="https://management.azure.com" 9 | API_versionOLD="2021-03-01-preview" 10 | API_version_incidents="2021-04-01" 11 | API_version_rules="2021-10-01" 12 | API_version_logs="2018-08-01-preview" 13 | API_version_templates="2023-02-01" 14 | scope="https://management.azure.com/.default" 15 | 16 | def __init__(self,credential,subscriptionId,resourceGroupName,workspaceName,workspace_id): 17 | 18 | self.subscriptionId = subscriptionId 19 | self.resourceGroupName = resourceGroupName 20 | self.workspaceName = workspaceName 21 | self.workspace_id=workspace_id 22 | self.access_token_timestamp=0 23 | self.credential=credential 24 | self.logs_client=LogsQueryClient(self.credential) 25 | 26 | def run_query(self,query,printresults=False): 27 | results_object={} 28 | try: 29 | response = self.logs_client.query_workspace( 30 | workspace_id=self.workspace_id, 31 | query=query, 32 | timespan=None 33 | ) 34 | if response.status == LogsQueryStatus.PARTIAL: 35 | error = response.partial_error 36 | data = response.partial_data 37 | print(error.message) 38 | elif response.status == LogsQueryStatus.SUCCESS: 39 | data = response.tables 40 | for table in data: 41 | df = pd.DataFrame(data=table.rows, columns=table.columns) 42 | if printresults: 43 | print(df) 44 | results_object={"status":"success","result":df.to_dict(orient="records")} 45 | except HttpResponseError as err: 46 | results_object={"status":"error","result":err} 47 | return results_object -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | azure-identity 2 | azure-monitor-query 3 | pyyaml 4 | msgraph-sdk 5 | python-dotenv 6 | pandas 7 | fastmcp 8 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jguimera/SecurityCopilotMCPServer/60a8de88234fd4608368ad687e6422723656ba2a/screenshot.png -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jguimera/SecurityCopilotMCPServer/60a8de88234fd4608368ad687e6422723656ba2a/screenshot2.png -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import argparse 4 | from dotenv import load_dotenv 5 | from mcp.server.fastmcp import FastMCP 6 | from azure.identity import InteractiveBrowserCredential, ClientSecretCredential, DefaultAzureCredential 7 | from SecurityCopilotClient import SecurityCopilotClient 8 | from SentinelClient import SentinelClient 9 | import logging 10 | 11 | # Configure logging 12 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 13 | logger = logging.getLogger('SecurityCopilotMCP') 14 | logger.info("Starting Security Copilot MCP Server") 15 | # Load environment variables 16 | load_dotenv() 17 | 18 | mcp = FastMCP('MicrosoftSecurityCopilot-server') 19 | 20 | securitycopilot_client = None 21 | sentinel_client = None 22 | 23 | @mcp.tool() 24 | def run_sentinel_query(query: str) -> str: 25 | """ Run a query against Sentinel. 26 | Args: 27 | query: The Kusto Query Language (KQL) query to run 28 | Returns: 29 | Results from the query 30 | """ 31 | global sentinel_client 32 | return sentinel_client.run_query(query) 33 | @mcp.tool() 34 | def upload_plugin(plugin_yaml_content: str, create_if_not_exists: bool = True) -> str: 35 | """ 36 | Upload or update a skillset in Security Copilot. 37 | 38 | Args: 39 | plugin_yaml_content: Raw YAML content of the plugin definition. Include the full file content 40 | create_if_not_exists: Whether to create the skillset if it doesn't exist 41 | 42 | Returns: 43 | Response from Security Copilot 44 | """ 45 | global securitycopilot_client 46 | 47 | try: 48 | results = securitycopilot_client.upload_skillset(plugin_yaml_content, create_if_not_exists) 49 | return results 50 | except Exception as e: 51 | return f"Error uploading skillset: {str(e)}" 52 | @mcp.tool() 53 | def get_skillsets(filter_name: str=None,full_response: bool = True) -> str: 54 | """Get skillsets from Security Copilot. 55 | Args: 56 | filter_name: Filter name to get skillsets from Security Copilot 57 | full_response: Whether to return the full response including skills from Security Copilot 58 | Returns: 59 | Skillsets from Security Copilot 60 | """ 61 | print("[+] Getting skillsets from Security Copilot with filter name: ",filter_name if filter_name else "None" ) 62 | try: 63 | global securitycopilot_client 64 | return securitycopilot_client.get_skillsets(filter_name,full_response) 65 | except Exception as e: 66 | return f"Error getting skillsets: {str(e)}" 67 | 68 | @mcp.tool() 69 | def run_prompt(prompt_type: str = "Prompt", content: str = None, skill_name: str = None, 70 | inputs: dict = None, session_name: str = "Security Copilot Session", 71 | polling_interval: int = 2, max_attempts: int = 30) -> str: 72 | """ 73 | Run a prompt in Security Copilot and get the results. 74 | 75 | Args: 76 | prompt_type: The type of prompt - "Prompt" or "Skill" 77 | content: The content of the prompt (required if prompt_type is "Prompt") 78 | skill_name: The name of the skill (required if prompt_type is "Skill") 79 | inputs: The inputs for the skill (required if prompt_type is "Skill") 80 | session_name: The name for the new session 81 | polling_interval: Time in seconds between polling attempts 82 | max_attempts: Maximum number of polling attempts 83 | 84 | Returns: 85 | Results from Security Copilot 86 | """ 87 | global securitycopilot_client 88 | 89 | try: 90 | results = securitycopilot_client.process_prompt( 91 | prompt_type=prompt_type, 92 | content=content, 93 | skill_name=skill_name, 94 | inputs=inputs, 95 | session_name=session_name, 96 | polling_interval=polling_interval, 97 | max_attempts=max_attempts 98 | ) 99 | return results 100 | except Exception as e: 101 | return f"Error processing prompt: {str(e)}" 102 | 103 | def run_tests(): 104 | """Run test queries to verify functionality.""" 105 | print("[+] Running Sentinel Test") 106 | sentinel_result = run_sentinel_query("Usage |project DataType | take 10") 107 | if sentinel_result["status"] == "success": 108 | print("[+] Sentinel Test executed successfully") 109 | else: 110 | print("[+] Sentinel Test failed") 111 | print("[+] Running Security Copilot Prompt Test") 112 | prompt_result = run_prompt(prompt_type="Prompt", content="What is the most common alert type in defender for the last 24 hours?") 113 | if "session_id" in prompt_result and "prompt_id" in prompt_result and "evaluation_id" in prompt_result: 114 | print("[+] Security Copilot Prompt Test executed successfully") 115 | else: 116 | print("[+] Security Copilot Prompt Test failed") 117 | # Run Security copilot Skill prompt test 118 | print("[+] Running Security Copilot Skill Test") 119 | skill_result = run_prompt(prompt_type="Skill", skill_name="GetAbnormalSignIns", inputs={"UniquePropertiesThreshold": "3", "Period": "24h", "Limit": "10"}) 120 | if "session_id" in skill_result and "prompt_id" in skill_result and "evaluation_id" in skill_result: 121 | print("[+] Security Copilot Skill Test executed successfully") 122 | else: 123 | print("[+] Security Copilot Skill Test failed") 124 | print("[+] Running Security Copilot Skillsets Test") 125 | skillsets = get_skillsets("Entra",full_response=False) 126 | if skillsets["count"] > 0: 127 | print("[+] Security Copilot Skillsets Test executed successfully") 128 | else: 129 | print("[+] Security Copilot Skillsets Test failed") 130 | def auth(auth_type): 131 | """ 132 | Authenticate with Azure using different credential types based on the provided auth_type. 133 | """ 134 | if auth_type == "interactive": 135 | credential = InteractiveBrowserCredential() 136 | elif auth_type == "client_secret": 137 | credential = ClientSecretCredential( 138 | tenant_id=os.getenv('AZURE_TENANT_ID'), 139 | client_id=os.getenv('AZURE_CLIENT_ID'), 140 | client_secret=os.getenv('AZURE_CLIENT_SECRET') 141 | ) 142 | else: 143 | # Default credential for managed identities 144 | credential = DefaultAzureCredential() 145 | # Force authentication to make the user login 146 | try: 147 | credential.get_token("https://api.securitycopilot.microsoft.com/.default") 148 | except Exception as e: 149 | print(f"Authentication failed: {e}") 150 | print("Only unauthenticated tools can be used") 151 | return credential 152 | 153 | def create_clients(auth_credential): 154 | """ 155 | Create clients to external platforms using environment variables. 156 | """ 157 | global securitycopilot_client, sentinel_client 158 | 159 | if securitycopilot_client is None: 160 | securitycopilot_client = SecurityCopilotClient(auth_credential) 161 | 162 | if sentinel_client is None: 163 | subscriptionId=os.getenv('SENTINEL_SUBSCRIPTION_ID') 164 | resourceGroupName=os.getenv('SENTINEL_RESOURCE_GROUP') 165 | workspaceName=os.getenv('SENTINEL_WORKSPACE_NAME') 166 | workspace_id=os.getenv('SENTINEL_WORKSPACE_ID') 167 | sentinel_client = SentinelClient(auth_credential,subscriptionId,resourceGroupName,workspaceName,workspace_id) 168 | return securitycopilot_client, sentinel_client 169 | 170 | if __name__ == "__main__": 171 | # Parse command line arguments 172 | parser = argparse.ArgumentParser(description="Security Copilot MCP Server") 173 | parser.add_argument("--run-tests", action="store_true", help="Run tests before starting the server") 174 | args = parser.parse_args() 175 | 176 | #Create clients 177 | print("[+] Authenticating using ", os.getenv('AUTHENTICATION_TYPE', 'interactive')) 178 | auth_credential = auth(os.getenv('AUTHENTICATION_TYPE', 'interactive')) 179 | create_clients(auth_credential) 180 | 181 | # Run tools test only if specified 182 | if args.run_tests: 183 | print("[+] Running tests...") 184 | run_tests() 185 | 186 | # Run MCP server with SSE transport 187 | print("[+] Starting MCP server...") 188 | mcp.run(transport="sse") 189 | 190 | 191 | 192 | 193 | 194 | --------------------------------------------------------------------------------