Beginner โฑ 90 min ๐Ÿ“‹ 10 Steps

Build a Sentinel MCP Server with KQL Tools

Create a Model Context Protocol server that exposes Microsoft Sentinel KQL query capabilities as tools, configure authentication, define MCP tool schemas, test with a local MCP client, and validate query results.

๐Ÿ“‹ Overview

About This Lab

This lab walks you through building a Model Context Protocol (MCP) server that integrates with Microsoft Sentinel, exposing KQL query capabilities as tools that AI models can discover and invoke. You will cover end-to-end project setup, Azure authentication configuration, tool schema design, KQL query implementation, error handling, testing with a local MCP client, and result validation. giving you a production-ready foundation for AI-driven security operations.

๐Ÿข Enterprise Use Case

A SOC team wants to enable their AI assistant to directly query Sentinel data during investigations. Currently analysts manually copy KQL queries between tools, slowing response times and introducing errors.

Building an MCP server standardizes this integration, allowing any MCP-compatible AI client to run Sentinel queries, retrieve incidents, and analyze security data programmatically. This eliminates context-switching, accelerates incident triage, and ensures consistent, auditable query execution across the team.

๐ŸŽฏ What You Will Learn

  1. Understand the Model Context Protocol architecture and how servers expose tools to AI clients
  2. Scaffold and configure an MCP server project with TypeScript and the MCP SDK
  3. Set up Azure authentication using DefaultAzureCredential for secure Sentinel access
  4. Define MCP tool schemas with typed input parameters and descriptions
  5. Implement KQL query execution against a Log Analytics workspace
  6. Build structured response formatting so AI models can interpret query results
  7. Add robust error handling and retry logic for Azure API calls
  8. Test the MCP server locally using an MCP Inspector or compatible client
  9. Validate query results against expected Sentinel data
  10. Prepare the server for production deployment and integration with Security Copilot

๐Ÿ”‘ Why This Matters

The Model Context Protocol is becoming the standard for AI-tool integration. Security teams that build MCP servers gain the ability to connect any AI model to their security stack without custom integrations. This lab teaches the foundational skills needed to bridge the gap between AI assistants and enterprise security infrastructure. enabling faster investigations, automated data retrieval, and a scalable pattern that extends to any security service with an API.

โš™๏ธ Prerequisites

  • Python 3.10+. or Node.js 18+ installed on your development machine
  • Azure subscription. with an active Microsoft Sentinel workspace containing sample data (SecurityIncident, SecurityAlert, SigninLogs tables)
  • Microsoft Entra ID app registration. with Log Analytics Reader role assignment on the Sentinel workspace
  • Basic understanding of REST APIs and JSON. familiarity with HTTP methods and JSON schemas
  • Git installed. for version control of your MCP server project
  • VS Code. recommended IDE with the Python or Node.js extensions installed
๐Ÿ’ก Pro Tip: If you do not have a Sentinel workspace with data, complete Sentinel Lab 01 first. You will need at least a few days of ingested logs for meaningful KQL results.

Step 1 ยท Understand the MCP Architecture

The Model Context Protocol (MCP) defines a standard interface between AI models and external tools. An MCP server exposes capabilities (tools) that AI clients can discover, invoke, and receive results from. much like a specialized API designed for AI consumption.

Key Concepts to Review

  1. Navigate to modelcontextprotocol.io and read the Introduction section
  2. Understand Tools. functions the server exposes that AI clients can call with typed parameters
  3. Understand Resources. read-only data the server provides (files, database records, configurations)
  4. Understand Prompts. pre-built templates for common interactions that guide AI behaviour
  5. Understand Transports. how client and server communicate: stdio for local, SSE for remote
  6. Review the architecture diagram: Host App โ†’ MCP Client โ†’ MCP Server โ†’ External System

MCP Architecture for Sentinel

Architecture Flow
AI Host App
VS Code, Claude, etc.
MCP Client
Built into the host
Sentinel MCP Server
run_kql_query, list_tables, get_incidents
Azure Log Analytics
Sentinel Workspace
๐Ÿ’ก Pro Tip: Bookmark the MCP Architecture docs. Understanding the client-server relationship is essential. your server never initiates communication; it only responds to client requests.

Step 2 ยท Set Up the Project Structure

Create a well-structured project with proper dependency management. This lab uses Python, but the same concepts apply to Node.js/TypeScript.

Python Setup

  1. Open a terminal and create the project directory
  2. Initialise a virtual environment
  3. Install the MCP SDK and Azure dependencies
  4. Create the project file structure
# Create the project
mkdir sentinel-mcp-server
cd sentinel-mcp-server

# Set up Python virtual environment
python -m venv .venv

# Activate (Windows)
.venv\Scripts\activate

# Activate (macOS/Linux)
# source .venv/bin/activate

# Install dependencies
pip install "mcp[cli]" azure-identity azure-monitor-query python-dotenv

# Create the project structure
mkdir -p src tests
touch src/__init__.py src/server.py src/tools.py src/auth.py
touch .env .env.example requirements.txt README.md

Node.js Alternative

# Node.js/TypeScript alternative setup
mkdir sentinel-mcp-server && cd sentinel-mcp-server
npm init -y

# Install runtime dependencies:
#   @modelcontextprotocol/sdk - MCP server framework (tool registration, transport)
#   @azure/identity          - Entra ID authentication (DefaultAzureCredential)
#   @azure/monitor-query     - Execute KQL queries against Log Analytics
#   dotenv                   - Load .env variables into process.env
npm install @modelcontextprotocol/sdk @azure/identity @azure/monitor-query dotenv

# Install dev dependencies: TypeScript compiler and Node.js type definitions
npm install -D typescript @types/node

# Generate tsconfig.json - configure TypeScript compilation options
npx tsc --init

Project Structure

sentinel-mcp-server/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”œโ”€โ”€ server.py          # MCP server entry point
โ”‚   โ”œโ”€โ”€ tools.py           # Tool definitions and handlers
โ”‚   โ””โ”€โ”€ auth.py            # Azure authentication module
โ”œโ”€โ”€ tests/
โ”‚   โ””โ”€โ”€ test_tools.py      # Unit tests for tools
โ”œโ”€โ”€ .env                   # Environment variables (never commit!)
โ”œโ”€โ”€ .env.example           # Template for .env
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ requirements.txt
โ””โ”€โ”€ README.md
โš ๏ธ Important: Add .env and .venv/ to your .gitignore immediately. Never commit Azure credentials to version control.

Step 3 ยท Configure Azure Authentication

Create an Entra ID app registration and configure secure authentication for your MCP server to access Sentinel data.

Register the Application in Entra ID

  1. Sign in to entra.microsoft.com
  2. Navigate to Identity > Applications > App registrations
  3. Click New registration
  4. Name: sentinel-mcp-server
  5. Supported account types: Single tenant
  6. Click Register
  7. Copy the Application (client) ID and Directory (tenant) ID
  8. Navigate to Certificates & secrets > New client secret
  9. Description: mcp-server-secret, Expiry: 6 months
  10. Copy the secret Value immediately (it will not be shown again)

Assign Sentinel Reader Role

# Grant the Entra ID app read-only access to the Sentinel workspace
# "Log Analytics Reader" allows executing KQL queries but NOT modifying data
# This follows least-privilege: the MCP server only needs to read logs
# Replace placeholders with your actual values from the Azure portal
# Output: JSON object confirming the role assignment with principalId
az role assignment create \
  --assignee "<APP_CLIENT_ID>" \
  --role "Log Analytics Reader" \
  --scope "/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.OperationalInsights/workspaces/<WORKSPACE_NAME>"

Create the .env File

# .env - Azure authentication and MCP server configuration
# SECURITY: Never commit this file to version control!

# Entra ID tenant where the app is registered (Azure Portal > Entra ID > Overview)
AZURE_TENANT_ID=your-tenant-id-here
# Application (client) ID from the app registration overview page
AZURE_CLIENT_ID=your-client-id-here
# Client secret value - copy immediately after creation (shown only once)
AZURE_CLIENT_SECRET=your-client-secret-here
# Log Analytics workspace ID (Sentinel > Settings > Workspace settings)
SENTINEL_WORKSPACE_ID=your-workspace-id-here

# Query guardrails - prevent AI clients from running expensive queries
MAX_QUERY_TIMESPAN=30     # Max days an AI client can look back
MAX_RESULTS=1000          # Max rows returned per query response

Authentication Module (src/auth.py)

import os
from dotenv import load_dotenv
from azure.identity import ClientSecretCredential, DefaultAzureCredential
from azure.monitor.query import LogsQueryClient

# Load environment variables from .env file into os.environ
load_dotenv()

def get_credential():
    """Get Azure credential using a two-tier authentication strategy.
    Returns: TokenCredential for authenticating Azure SDK calls.
    - Production: ClientSecretCredential with explicit Entra ID app secrets
    - Development: DefaultAzureCredential (tries az login, managed identity, etc.)
    """
    if os.getenv('AZURE_CLIENT_SECRET'):
        # Production path: authenticate using the Entra ID app registration
        # Requires AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET
        return ClientSecretCredential(
            tenant_id=os.environ['AZURE_TENANT_ID'],
            client_id=os.environ['AZURE_CLIENT_ID'],
            client_secret=os.environ['AZURE_CLIENT_SECRET']
        )
    # Development path: automatically tries multiple credential sources
    # in order: environment vars, managed identity, az CLI, VS Code, etc.
    return DefaultAzureCredential()

def get_logs_client():
    """Create a LogsQueryClient for executing KQL queries.
    This client connects to Azure Monitor / Log Analytics
    and is used by all MCP tools that query Sentinel data.
    Returns: Authenticated LogsQueryClient instance.
    """
    credential = get_credential()
    return LogsQueryClient(credential)

# The Log Analytics workspace ID that contains Sentinel data
# All KQL queries from MCP tools will target this workspace
WORKSPACE_ID = os.environ.get('SENTINEL_WORKSPACE_ID', '')
๐Ÿ’ก Pro Tip: Use DefaultAzureCredential during local development. it automatically picks up your az login session. Switch to ClientSecretCredential for production deployments. This pattern lets you develop without managing secrets locally.

Step 4 ยท Create the MCP Server Foundation

Initialise the MCP server with the SDK, define server metadata, and configure the stdio transport for local testing.

Server Entry Point (src/server.py)

import asyncio
import json
from datetime import timedelta

# MCP SDK imports:
# Server - core framework class for building MCP servers
from mcp.server import Server
# stdio_server - provides stdin/stdout transport for local process communication
from mcp.server.stdio import stdio_server
# types - MCP protocol types: Tool (schema), TextContent (response format)
import mcp.types as types
# Local auth module - provides authenticated Azure SDK clients
from auth import get_logs_client, WORKSPACE_ID

# Create the MCP server instance with a unique identifier
# This name is sent to MCP clients during the initialization handshake
# and helps clients distinguish between multiple connected servers
server = Server("sentinel-mcp-server")

# Create a singleton Azure Log Analytics client
# Reused across all tool invocations to avoid repeated authentication
logs_client = get_logs_client()

# MCP Tool Discovery: @server.list_tools() registers this function
# as the handler for tool listing requests from MCP clients.
# When a client connects, it calls this to discover available tools.
# Each Tool needs: name, description (AI uses this to decide when to call),
# and inputSchema (JSON Schema defining the accepted parameters).
@server.list_tools()
async def list_tools() -> list[types.Tool]:
    """Return the list of tools this server provides.
    Called automatically by MCP clients during tool discovery."""
    return [
        # Tool 1: Execute arbitrary KQL queries against Sentinel
        types.Tool(
            name="run_kql_query",
            description="Execute a KQL query against the Microsoft Sentinel "
                        "Log Analytics workspace and return results as JSON.",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "The KQL query to execute"
                    },
                    "days": {
                        "type": "integer",
                        "description": "Number of days to look back (default: 7, max: 30)",
                        "default": 7
                    }
                },
                "required": ["query"]
            }
        ),
        # Tool 2: Discover available tables in the workspace
        # Helps AI clients know what data exists before building queries
        types.Tool(
            name="list_sentinel_tables",
            description="List available log tables in the Sentinel workspace "
                        "with row counts to help construct valid KQL queries.",
            inputSchema={
                "type": "object",
                "properties": {},
                "required": []
            }
        ),
        # Tool 3: Purpose-built tool for retrieving security incidents
        # More structured than raw KQL - returns pre-formatted incident data
        types.Tool(
            name="get_recent_incidents",
            description="Retrieve recent security incidents from Sentinel "
                        "with severity, status, and assigned owner.",
            inputSchema={
                "type": "object",
                "properties": {
                    "severity": {
                        "type": "string",
                        "description": "Filter by severity: High, Medium, Low, Informational",
                        "enum": ["High", "Medium", "Low", "Informational"]
                    },
                    "days": {
                        "type": "integer",
                        "description": "Number of days to look back (default: 7)",
                        "default": 7
                    }
                },
                "required": []
            }
        )
    ]

async def main():
    """Run the MCP server using stdio transport.
    stdio = communication via standard input/output pipes.
    The MCP client (e.g., VS Code, Claude) spawns this as a child process
    and sends JSON-RPC messages over stdin, reading responses from stdout.
    """
    async with stdio_server() as (read_stream, write_stream):
        # server.run() starts the MCP protocol handshake and message loop
        # It handles: initialization, tool discovery, tool invocation, shutdown
        await server.run(
            read_stream, write_stream,
            server.create_initialization_options()
        )

if __name__ == "__main__":
    # Entry point: start the async event loop and run the MCP server
    asyncio.run(main())
๐Ÿ’ก Pro Tip: Start with stdio transport for local development and testing. Once your tools are working correctly, add SSE transport for production deployment (covered in Lab 03). This two-phase approach lets you iterate quickly without dealing with network issues.

Step 5 ยท Implement the KQL Query Tool

Implement the run_kql_query tool handler that accepts a KQL query string and time range, executes it against your Sentinel workspace, and returns structured JSON results.

Tool Handler Implementation

from azure.monitor.query import LogsQueryStatus
from azure.core.exceptions import HttpResponseError

# MCP Tool Execution: @server.call_tool() registers this as the handler
# for all tool invocation requests. When an AI client calls a tool,
# this function dispatches to the appropriate handler based on tool name.
@server.call_tool()
async def call_tool(
    name: str, arguments: dict
) -> list[types.TextContent]:
    """Handle tool invocation requests from MCP clients.
    Dispatches to the correct handler based on the tool name.
    Returns: list of TextContent with JSON-formatted results.
    """

    # Route the tool call to the appropriate handler
    if name == "run_kql_query":
        return await handle_kql_query(arguments)
    elif name == "list_sentinel_tables":
        return await handle_list_tables(arguments)
    elif name == "get_recent_incidents":
        return await handle_get_incidents(arguments)
    else:
        return [types.TextContent(
            type="text",
            text=f"Error: Unknown tool '{name}'"
        )]

async def handle_kql_query(arguments: dict) -> list[types.TextContent]:
    """Execute a KQL query against the Sentinel workspace.
    Accepts a KQL query string and optional time range (days).
    Returns: JSON with status, row_count, and query results array.
    """
    query = arguments.get("query", "")
    # Security guardrail: cap the time range to prevent expensive full-scan queries
    days = min(int(arguments.get("days", 7)), 30)  # Cap at 30 days
    timespan = timedelta(days=days)

    try:
        # Execute the KQL query against the Log Analytics workspace
        response = logs_client.query_workspace(
            workspace_id=WORKSPACE_ID,
            query=query,
            timespan=timespan
        )

        if response.status == LogsQueryStatus.SUCCESS:
            # Convert tabular results to a list of dicts for JSON serialization
            # Each row becomes {column_name: value} for easy AI consumption
            rows = []
            for table in response.tables:
                columns = [col.name for col in table.columns]
                for row in table.rows:
                    rows.append(dict(zip(columns, row)))

            # Structure the response for AI-friendly consumption
            # Include metadata (query, timespan, count) alongside results
            result = {
                "status": "success",
                "query": query,
                "timespan_days": days,
                "row_count": len(rows),
                "results": rows[:1000]  # Limit results to prevent huge payloads
            }
            return [types.TextContent(
                type="text", text=json.dumps(result, default=str)
            )]
        else:
            return [types.TextContent(
                type="text",
                text=json.dumps({
                    "status": "partial",
                    "message": "Query returned partial results",
                    "error": str(response.partial_error)
                })
            )]

    except HttpResponseError as e:
        return [types.TextContent(
            type="text",
            text=json.dumps({
                "status": "error",
                "error_code": e.error.code if e.error else "unknown",
                "message": str(e.message),
                "suggestion": "Check your KQL syntax or narrow the time range."
            })
        )]
โš ๏ธ Important: Always cap the time range and result set size. An AI model could unknowingly request years of data, which would be expensive and slow. The min(days, 30) and rows[:1000] guards protect your workspace.

Step 6 ยท Add a Table Schema Tool

The table schema tool helps AI clients understand what data is available before constructing queries. Without it, the AI model would need to guess table and column names, leading to failed queries.

Implementation

async def handle_list_tables(arguments: dict) -> list[types.TextContent]:
    """List available Sentinel tables with row counts.
    This discovery tool helps AI clients understand what data
    is available before constructing KQL queries, improving
    first-attempt query accuracy.
    Returns: JSON with table names, 24h row counts, and descriptions.
    """
    # KQL query to discover all tables and their recent activity
    # search * scans across all tables; $table captures the source table name
    query = """
    search *
    | summarize Count=count() by $table
    | order by Count desc
    | take 50
    """
    try:
        response = logs_client.query_workspace(
            workspace_id=WORKSPACE_ID,
            query=query,
            timespan=timedelta(days=1)
        )

        tables = []
        if response.status == LogsQueryStatus.SUCCESS:
            for table in response.tables:
                columns = [col.name for col in table.columns]
                for row in table.rows:
                    record = dict(zip(columns, row))
                    tables.append({
                        "table_name": record.get("$table", ""),
                        "row_count_24h": record.get("Count", 0)
                    })

        # Enrich tables with human-readable descriptions
        # These help AI models choose the correct table without guessing
        table_descriptions = {
            "SecurityIncident": "Sentinel incidents with severity, status, and owner",
            "SecurityAlert": "Alerts from all connected data sources",
            "SigninLogs": "Azure AD sign-in activity",
            "AuditLogs": "Azure AD audit trail",
            "CommonSecurityLog": "CEF-formatted logs from firewalls and appliances",
            "Syslog": "Linux syslog data",
            "ThreatIntelligenceIndicator": "Threat intelligence IOCs",
            "AzureActivity": "Azure control plane operations",
            "DeviceEvents": "Defender for Endpoint device events",
            "EmailEvents": "Defender for Office 365 email events"
        }

        for t in tables:
            t["description"] = table_descriptions.get(t["table_name"], "")

        return [types.TextContent(
            type="text",
            text=json.dumps({
                "status": "success",
                "table_count": len(tables),
                "tables": tables
            }, default=str)
        )]

    except Exception as e:
        return [types.TextContent(
            type="text",
            text=json.dumps({"status": "error", "message": str(e)})
        )]
๐Ÿ’ก Pro Tip: Including human-readable table descriptions enables the AI model to select the right table on its first attempt. This dramatically improves query accuracy and reduces wasted SCU usage in Security Copilot scenarios.

Step 7 ยท Add Input Validation & Security Guardrails

KQL injection is a real risk. an AI model might construct a query that is syntactically valid but excessively expensive. Implement guardrails to protect your Sentinel workspace.

Validation Module

import re

# Security: Regex patterns matching dangerous KQL management commands
# These are write/delete operations that could modify or destroy data
# An AI model should NEVER be allowed to execute these against Sentinel
BLOCKED_PATTERNS = [
    r'\.drop\s',           # Table drops - permanently deletes data
    r'\.set\s',            # Data modifications - writes new data
    r'\.create\s',         # Schema changes - creates tables/functions
    r'\.alter\s',          # Schema alterations - modifies table structure
    r'\.delete\s',         # Data deletion - removes specific records
]

# Guardrail limits to prevent expensive or oversized queries
MAX_TIMESPAN_DAYS = 30   # Prevent full-history scans
MAX_RESULTS = 1000       # Cap response payload size
MAX_QUERY_LENGTH = 5000  # Prevent excessively complex queries

def validate_kql_query(query: str, days: int) -> dict:
    """Validate a KQL query before execution.
    Checks for: blocked patterns, length limits, timespan limits.
    Returns: {"valid": True} or {"valid": False, "reason": "..."}.
    Why: AI models can generate syntactically valid but dangerous queries.
    """
    # Check query length
    if len(query) > MAX_QUERY_LENGTH:
        return {
            "valid": False,
            "reason": f"Query exceeds {MAX_QUERY_LENGTH} character limit. "
                      "Simplify the query or break it into multiple calls."
        }

    # Check for blocked patterns (KQL injection prevention)
    for pattern in BLOCKED_PATTERNS:
        if re.search(pattern, query, re.IGNORECASE):
            return {
                "valid": False,
                "reason": f"Query contains blocked operation. "
                          "Only read operations are allowed."
            }

    # Enforce timespan limit
    if days > MAX_TIMESPAN_DAYS:
        return {
            "valid": False,
            "reason": f"Timespan exceeds {MAX_TIMESPAN_DAYS} days. "
                      "Narrow your search window."
        }

    # Warn about missing 'take' or 'limit'
    if 'take' not in query.lower() and 'limit' not in query.lower():
        # Add a default limit
        pass  # We cap results in the handler anyway

    return {"valid": True}

Integrate Validation into the Tool Handler

# In handle_kql_query, add validation before execution:
# This ensures every query is checked for dangerous patterns
# and resource limits before it reaches the Azure API
async def handle_kql_query(arguments: dict) -> list[types.TextContent]:
    query = arguments.get("query", "")
    days = int(arguments.get("days", 7))

    # Validate before executing
    validation = validate_kql_query(query, days)
    if not validation["valid"]:
        return [types.TextContent(
            type="text",
            text=json.dumps({
                "status": "validation_error",
                "message": validation["reason"],
                "suggestion": "Modify your query and try again."
            })
        )]

    # Cap the timespan
    days = min(days, MAX_TIMESPAN_DAYS)
    # ... proceed with query execution
โš ๏ธ Important: These guardrails are essential for production deployments. Without them, an AI model could construct a search * | where TimeGenerated > ago(365d) query that scans your entire data lake, consuming significant resources and potentially timing out.

Step 8 ยท Implement the Incidents Tool

Add a dedicated tool for retrieving Sentinel incidents. This is one of the most common operations AI agents will perform. getting a view of the current threat landscape.

Incidents Tool Implementation

async def handle_get_incidents(arguments: dict) -> list[types.TextContent]:
    """Retrieve recent Sentinel incidents.
    Returns structured incident data including severity, status,
    timestamps, owner, and alert count for AI-powered triage.
    """
    # Cap timespan and extract optional severity filter
    days = min(int(arguments.get("days", 7)), 30)
    severity = arguments.get("severity", None)

    # Build KQL query dynamically based on parameters
    # SecurityIncident table stores Sentinel incident records
    query = """
    SecurityIncident
    | where TimeGenerated > ago({days}d)
    """.format(days=days)

    # Optionally filter by severity level
    if severity:
        query += f'| where Severity == "{severity}"\n'

    # Project only the fields an AI needs for triage decisions
    # Using KQL 'project' reduces data transfer and focuses the response
    query += """
    | project
        IncidentNumber,
        Title,
        Severity,
        Status,
        CreatedTime,
        LastModifiedTime,
        Owner = tostring(Owner.assignedTo),
        AlertsCount = array_length(AlertIds),
        Description
    | order by CreatedTime desc
    | take 25
    """

    try:
        response = logs_client.query_workspace(
            workspace_id=WORKSPACE_ID,
            query=query,
            timespan=timedelta(days=days)
        )

        incidents = []
        if response.status == LogsQueryStatus.SUCCESS:
            for table in response.tables:
                columns = [col.name for col in table.columns]
                for row in table.rows:
                    incidents.append(dict(zip(columns, row)))

        return [types.TextContent(
            type="text",
            text=json.dumps({
                "status": "success",
                "incident_count": len(incidents),
                "timespan_days": days,
                "severity_filter": severity or "all",
                "incidents": incidents
            }, default=str)
        )]

    except Exception as e:
        return [types.TextContent(
            type="text",
            text=json.dumps({"status": "error", "message": str(e)})
        )]
๐Ÿ’ก Pro Tip: Design tool outputs to include all context an AI model needs in a single response. Including AlertsCount, Owner, and Status together means the AI can provide a comprehensive summary without needing a second tool call.

Step 9 ยท Add Error Handling & Retry Logic

Good error messages are critical for AI interaction. the AI model needs to understand what went wrong to self-correct. Implement structured error responses with error codes, descriptions, and suggested remediations.

Error Handling Wrapper

import time
from functools import wraps

def with_retry(max_retries=3, base_delay=1):
    """Decorator that retries on transient Azure errors.
    Uses exponential backoff: delay doubles with each attempt.
    Handles 429 (rate limited) and 5xx (server errors) automatically.
    Client errors (4xx except 429) are NOT retried.
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(max_retries):
                try:
                    return await func(*args, **kwargs)
                except HttpResponseError as e:
                    last_exception = e
                    if e.status_code == 429:
                        # Rate limited by Azure - respect the Retry-After header
                        # to avoid being blocked entirely
                        retry_after = int(
                            e.response.headers.get('Retry-After', base_delay * (2 ** attempt))
                        )
                        time.sleep(retry_after)
                    elif e.status_code >= 500:
                        # Azure server error - retry with exponential backoff
                        time.sleep(base_delay * (2 ** attempt))
                    else:
                        # Client error (400, 403, 404) - don't retry, fix the request
                        raise
            raise last_exception
        return wrapper
    return decorator

def format_error(error: Exception) -> dict:
    """Format an exception into an AI-friendly error response.
    Structured errors help AI models understand what went wrong
    and self-correct on the next attempt.
    Returns: dict with status, error_code, message, and suggestion.
    """
    if isinstance(error, HttpResponseError):
        return {
            "status": "error",
            "error_code": error.error.code if error.error else str(error.status_code),
            "message": str(error.message),
            "suggestion": get_error_suggestion(error)
        }
    return {
        "status": "error",
        "error_code": "unknown",
        "message": str(error),
        "suggestion": "An unexpected error occurred. Check server logs."
    }

def get_error_suggestion(error: HttpResponseError) -> str:
    """Map common HTTP status codes to actionable remediation hints.
    The AI model uses these to fix issues without human intervention.
    """
    # Map status codes to user-friendly suggestions
    suggestions = {
        401: "Authentication failed. Check your Azure credentials in .env.",
        403: "Insufficient permissions. Verify the Log Analytics Reader role.",
        404: "Workspace not found. Check SENTINEL_WORKSPACE_ID in .env.",
        429: "Rate limited by Azure. The query will be retried automatically.",
    }
    return suggestions.get(error.status_code, "Check the query syntax and try again.")
๐Ÿ’ก Pro Tip: Include the suggestion field in every error response. AI models use this text to understand how to fix the issue and retry with a corrected request. without this, the model may fall into a retry loop with the same broken query.

Step 10 ยท Test with the MCP Inspector

Use the official MCP Inspector to test your server before connecting it to an AI client. The Inspector provides a visual interface to discover tools, invoke them, and inspect responses.

Launch the MCP Inspector

  1. Install the MCP CLI if you haven’t already: pip install "mcp[cli]"
  2. Start the inspector pointing to your server:
# Launch the MCP Inspector - a visual UI for testing MCP servers
# The Inspector connects via stdio, discovers tools, and lets you
# invoke them interactively to verify schemas and responses
mcp dev src/server.py

# The inspector opens in your browser at http://localhost:5173
# Use the Tools tab to see all registered tools and test them

Test Each Tool

  1. In the Inspector, click the Tools tab. verify all three tools appear with correct schemas
  2. Test list_sentinel_tables. click it and verify it returns available tables
  3. Test run_kql_query with a simple query:
# Test queries to run in the MCP Inspector
# These validate that your server correctly executes KQL and returns results

# Query 1: Simple incident list - verifies SecurityIncident table access
SecurityIncident | take 5

# Query 2: Alert summary by severity - verifies aggregation works
SecurityAlert
| summarize Count=count() by AlertSeverity
| order by Count desc

# Query 3: Sign-in anomaly detection - verifies cross-table access
# Finds users with >5 failed sign-ins (potential brute force)
SigninLogs
| where ResultType != "0"
| summarize FailedAttempts=count() by UserPrincipalName
| where FailedAttempts > 5
| order by FailedAttempts desc
  1. Test get_recent_incidents with and without a severity filter
  2. Test error handling. submit a query with invalid KQL syntax and verify you get a helpful error
  3. Test validation. submit a query with .drop and verify it is blocked

Connect to VS Code (Optional)

// MCP Client Configuration for VS Code
// Add to .vscode/mcp.json or VS Code settings.json
// This tells the VS Code MCP client how to launch your server
{
  "mcpServers": {
    // Server identifier - used in tool namespacing (sentinel.*)
    "sentinel": {
      // Command to launch the server process (stdio transport)
      "command": "python",
      // Path to the server entry point script
      "args": ["src/server.py"],
      // Working directory for the server process
      "cwd": "/path/to/sentinel-mcp-server",
      // Environment variables passed to the server process
      // These override any .env file values
      "env": {
        "AZURE_TENANT_ID": "your-tenant-id",
        "AZURE_CLIENT_ID": "your-client-id",
        "AZURE_CLIENT_SECRET": "your-client-secret",
        "SENTINEL_WORKSPACE_ID": "your-workspace-id"
      }
    }
  }
}
๐Ÿ’ก Pro Tip: Once connected to VS Code, you can ask Copilot: “Use the sentinel MCP server to show me the top 10 high-severity incidents from the last 24 hours” and it will invoke your tools automatically.

Step 11 ยท Document and Package the Server

Create comprehensive documentation so other team members can deploy and use your MCP server.

Create the .env.example Template

# .env.example - Template for team members to create their own .env
# Copy this file: cp .env.example .env
# Then fill in your values from the Azure portal

# Azure Authentication (from Entra ID > App registrations)
AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_CLIENT_SECRET=your-client-secret-here

# Sentinel Configuration (from Log Analytics workspace > Properties)
SENTINEL_WORKSPACE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Optional: Query Guardrails - limit what AI clients can do
# These protect your workspace from expensive or oversized queries
MAX_QUERY_TIMESPAN=30     # Maximum days for KQL queries
MAX_RESULTS=1000          # Maximum rows returned per query
MAX_QUERY_LENGTH=5000     # Maximum KQL query string length

Generate requirements.txt

# Freeze current package versions for reproducible deployments
pip freeze > requirements.txt

# Or manually create a minimal requirements.txt with version floors:
mcp[cli]>=1.0.0              # MCP server framework with CLI tools
azure-identity>=1.15.0       # Entra ID authentication (DefaultAzureCredential)
azure-monitor-query>=1.3.0   # KQL query execution against Log Analytics
python-dotenv>=1.0.0         # Load .env files into environment

Clean Up & Next Steps

  • Next Lab: Create a Defender XDR MCP Server
  • Commit your code to Git (excluding .env and .venv/)
  • Write a README with setup instructions, tool descriptions, and usage examples
  • Consider adding unit tests in tests/test_tools.py to validate tool schemas
๐Ÿ’ก Pro Tip: Create a configuration file template listing all required environment variables. Include clear comments explaining where to find each value in the Azure portal. This dramatically reduces onboarding time for new team members.

๐Ÿ“š Documentation Resources

ResourceDescription
Introduction to Model Context ProtocolOfficial MCP specification and architecture
MCP ToolsDefine and implement tools for AI model interaction
Log Analytics Query APIExecute KQL queries via REST API
Azure Monitor Logs API overviewProgrammatic access to Log Analytics data
Extend Sentinel across workspacesMulti-workspace architecture patterns
MCP ResourcesExpose data and context to AI models via resources

Summary

What You Accomplished

  • Understood the Model Context Protocol architecture and how servers expose tools to AI clients
  • Scaffolded an MCP server project with Python (or Node.js) and installed the MCP SDK and Azure dependencies
  • Registered an Entra ID application and configured secure authentication with DefaultAzureCredential
  • Created the MCP server foundation with tool discovery and stdio transport
  • Defined MCP tool schemas for run_kql_query, list_sentinel_tables, and get_recent_incidents
  • Implemented KQL query execution against a Log Analytics workspace with structured response formatting
  • Added error handling and retry logic for Azure API calls
  • Tested the MCP server locally using an MCP Inspector or compatible client
  • Validated query results against expected Sentinel data
  • Prepared the server for production deployment and integration with Security Copilot

Next Steps

  • Add MCP resources to expose static Sentinel data like watchlists and table schemas
  • Build a Defender XDR MCP server for incident management and advanced hunting
  • Deploy your MCP server to Azure with SSE transport for remote access
  • Continue to Lab 02 - Build a Defender XDR MCP Server
← All Labs Next Lab →