diff --git a/.env.example b/.env.example
deleted file mode 100644
index 1f76893..0000000
--- a/.env.example
+++ /dev/null
@@ -1,49 +0,0 @@
-# ============================================
-# MCP Azure DevOps Server - Configuration
-# ============================================
-# Copy this file to .env and fill in your values:
-# cp .env.example .env
-#
-# IMPORTANT: Never commit the .env file to version control!
-# ============================================
-
-# --------------------------------------------
-# REQUIRED: Azure DevOps Organization URL
-# --------------------------------------------
-# Format: https://dev.azure.com/{organization}
-# Example: https://dev.azure.com/contoso
-#
-AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
-
-# --------------------------------------------
-# REQUIRED: Personal Access Token (PAT)
-# --------------------------------------------
-# Create at: https://dev.azure.com/{organization}/_usersSettings/tokens
-#
-# Required scopes:
-# - Work Items: Read & Write
-# - Code: Read
-# - Build: Read
-#
-AZURE_DEVOPS_PAT=your-personal-access-token
-
-# --------------------------------------------
-# OPTIONAL: Default Project
-# --------------------------------------------
-# If set, this project will be used when no project is specified
-# Must match exact project name in Azure DevOps
-#
-AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name
-
-# --------------------------------------------
-# OPTIONAL: Server Security
-# --------------------------------------------
-# API key for authenticating requests to the MCP server
-# Generate a strong random key (e.g., openssl rand -base64 32)
-#
-MCP_API_KEY=
-
-# Enable/disable API key authentication (true/false)
-# When enabled, all requests (except /health) require a valid API key
-#
-MCP_REQUIRE_API_KEY=false
diff --git a/README.md b/README.md
index f7f04f1..0f51ed5 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,40 @@ This project implements an MCP server that exposes tools for querying and managi
| `get_build_timeline` | Gets the timeline (stages, jobs, tasks) for a build |
| `query_builds` | Advanced query with multiple combined filters |
+### Analytics Tools
+
+Tools for analyzing delivery performance, identifying bottlenecks, and tracking work health.
+
+| Tool | Description |
+|------|-------------|
+| `get_flow_metrics` | Calculates Lead Time, Cycle Time, and Throughput with statistical analysis |
+| `compare_flow_metrics` | Compares metrics between two periods to identify trends |
+| `get_wip_analysis` | Analyzes Work in Progress by state, area, and person |
+| `get_bottlenecks` | Identifies workflow bottlenecks with severity scores and recommendations |
+| `get_team_workload` | Analyzes workload distribution across team members |
+| `get_aging_report` | Reports aging work items with urgency classification and recommendations |
+
+#### Flow Metrics
+
+- **Lead Time**: Time from work item creation to completion (measures total delivery time)
+- **Cycle Time**: Time from work started to completion (measures active work time)
+- **Throughput**: Number of items completed per period
+
+#### WIP Analysis
+
+- **By State**: Where work is accumulating
+- **By Area**: Which teams/squads are overloaded
+- **By Person**: Who has too many items assigned
+- **Aging Items**: Work that has been stuck too long
+
+#### Aging Report
+
+Items are classified by urgency based on:
+- Priority (P1 items age faster than P4)
+- Time since last update
+- Current state (blocked items are more urgent)
+- Assignment status (unassigned items need attention)
+
---
## Prerequisites
@@ -368,6 +402,19 @@ After configuring the MCP client, you can ask questions like:
- "Show me the timeline of build #123"
- "What builds are currently in progress?"
+### Analytics and Insights
+
+- "What's our average delivery time (lead time)?"
+- "Are we delivering faster or slower than last month?"
+- "Where is work piling up? Show me bottlenecks"
+- "Who on the team is overloaded?"
+- "What items have been stuck for too long?"
+- "Show me aging work items that need attention"
+- "How many items did we complete last week?"
+- "What's our cycle time for bugs vs user stories?"
+- "Is the 'Backend' team overloaded?"
+- "Compare this sprint's throughput to the previous sprint"
+
---
## Troubleshooting
@@ -459,8 +506,19 @@ mcp-azure-devops/
│ ├── Configuration/ # App configuration classes
│ ├── Middleware/ # HTTP middleware (authentication, etc.)
│ ├── Models/ # DTOs and data models
+│ │ ├── WorkItemDto.cs # Work item models
+│ │ ├── RepositoryDto.cs # Git repository models
+│ │ ├── PullRequestDto.cs # Pull request models
+│ │ ├── PipelineDto.cs # Pipeline/build models
+│ │ ├── FlowMetricsDto.cs # Flow metrics models
+│ │ └── WipAnalysisDto.cs # WIP and aging models
│ ├── Services/ # Azure DevOps SDK integration
│ ├── Tools/ # MCP tool implementations
+│ │ ├── WorkItemTools.cs # Work item operations
+│ │ ├── GitTools.cs # Git repository operations
+│ │ ├── PullRequestTools.cs # Pull request operations
+│ │ ├── PipelineTools.cs # Pipeline/build operations
+│ │ └── AnalyticsTools.cs # Analytics and metrics
│ ├── Program.cs # Entry point
│ ├── appsettings.json # App settings
│ └── Dockerfile # Container definition
@@ -471,7 +529,6 @@ mcp-azure-devops/
│ ├── Models/ # DTO tests
│ └── Tools/ # Tool behavior tests
├── .github/ # GitHub templates
-├── .env.example # Environment template (Docker)
├── docker-compose.yml # Docker orchestration
├── CONTRIBUTING.md # Contributor guide
├── CODE_OF_CONDUCT.md # Community guidelines
@@ -545,6 +602,44 @@ Build log metadata with Id, Type, Url, LineCount, and timestamps.
#### BuildTimelineRecordDto
Timeline record (stage, job, or task) with Id, ParentId, Type, Name, State, Result, timing information, and error/warning counts.
+### Analytics DTOs
+
+#### FlowMetricsDto
+Flow metrics analysis results including:
+- PeriodStart, PeriodEnd, Throughput (total completed items)
+- LeadTime, CycleTime (MetricStatistics with average, median, percentiles)
+- ThroughputByPeriod (breakdown by day/week)
+- ThroughputByType (breakdown by work item type)
+- Items (optional detailed list)
+
+#### MetricStatistics
+Statistical measures for a metric including Average, Median, Percentile85, Percentile95, Min, Max, StdDev, and Count.
+
+#### WipAnalysisDto
+Work in Progress analysis including:
+- TotalWip, ByState, ByArea, ByPerson, ByType
+- AgingItems (items stuck too long)
+- Insights (auto-generated observations)
+
+#### BottleneckAnalysisDto
+Bottleneck identification results with:
+- Bottlenecks (list ordered by severity)
+- Recommendations (actionable suggestions)
+
+#### BottleneckDto
+Individual bottleneck with Type (State/Person/Area), Location, ItemCount, Severity (1-10), Description, and SampleItemIds.
+
+#### AgingReportDto
+Comprehensive aging analysis including:
+- TotalAgingItems, Summary (statistics)
+- ByUrgency (Critical/High/Medium/Low item IDs)
+- ByState, ByAssignee, ByArea (grouped counts)
+- Items (detailed list with recommendations)
+- Recommendations (overall action items)
+
+#### AgingItemDetailDto
+Detailed aging item with Id, Title, WorkItemType, State, AssignedTo, AreaPath, Priority, DaysSinceUpdate, DaysSinceCreation, Urgency, UrgencyScore, and Recommendation.
+
---
## Development
diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs
new file mode 100644
index 0000000..d3aaaeb
--- /dev/null
+++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs
@@ -0,0 +1,170 @@
+namespace Viamus.Azure.Devops.Mcp.Server.Models;
+
+///
+/// Flow metrics analysis results.
+///
+public sealed record FlowMetricsDto
+{
+ ///
+ /// Analysis period start date.
+ ///
+ public DateTime PeriodStart { get; init; }
+
+ ///
+ /// Analysis period end date.
+ ///
+ public DateTime PeriodEnd { get; init; }
+
+ ///
+ /// Total number of completed work items in the period.
+ ///
+ public int Throughput { get; init; }
+
+ ///
+ /// Lead time statistics in days (from creation to completion).
+ ///
+ public MetricStatistics LeadTime { get; init; } = new();
+
+ ///
+ /// Cycle time statistics in days (from started to completion).
+ ///
+ public MetricStatistics CycleTime { get; init; } = new();
+
+ ///
+ /// Throughput breakdown by period (day/week).
+ ///
+ public List ThroughputByPeriod { get; init; } = [];
+
+ ///
+ /// Throughput breakdown by work item type.
+ ///
+ public Dictionary ThroughputByType { get; init; } = new();
+
+ ///
+ /// Individual work item flow data for detailed analysis.
+ ///
+ public List Items { get; init; } = [];
+}
+
+///
+/// Statistical measures for a metric.
+///
+public sealed record MetricStatistics
+{
+ ///
+ /// Average value.
+ ///
+ public double Average { get; init; }
+
+ ///
+ /// Median value (50th percentile).
+ ///
+ public double Median { get; init; }
+
+ ///
+ /// 85th percentile value.
+ ///
+ public double Percentile85 { get; init; }
+
+ ///
+ /// 95th percentile value.
+ ///
+ public double Percentile95 { get; init; }
+
+ ///
+ /// Minimum value.
+ ///
+ public double Min { get; init; }
+
+ ///
+ /// Maximum value.
+ ///
+ public double Max { get; init; }
+
+ ///
+ /// Standard deviation.
+ ///
+ public double StdDev { get; init; }
+
+ ///
+ /// Number of items in the sample.
+ ///
+ public int Count { get; init; }
+}
+
+///
+/// Throughput data for a specific period.
+///
+public sealed record ThroughputPeriod
+{
+ ///
+ /// Period start date.
+ ///
+ public DateTime PeriodStart { get; init; }
+
+ ///
+ /// Period end date.
+ ///
+ public DateTime PeriodEnd { get; init; }
+
+ ///
+ /// Number of items completed in this period.
+ ///
+ public int Count { get; init; }
+}
+
+///
+/// Flow data for a single work item.
+///
+public sealed record WorkItemFlowDataDto
+{
+ ///
+ /// Work item ID.
+ ///
+ public int Id { get; init; }
+
+ ///
+ /// Work item title.
+ ///
+ public string? Title { get; init; }
+
+ ///
+ /// Work item type (Bug, Task, User Story, etc.).
+ ///
+ public string? WorkItemType { get; init; }
+
+ ///
+ /// Date the work item was created.
+ ///
+ public DateTime? CreatedDate { get; init; }
+
+ ///
+ /// Date the work item was started (entered In Progress or equivalent).
+ ///
+ public DateTime? StartedDate { get; init; }
+
+ ///
+ /// Date the work item was completed.
+ ///
+ public DateTime? CompletedDate { get; init; }
+
+ ///
+ /// Lead time in days (created to completed).
+ ///
+ public double? LeadTimeDays { get; init; }
+
+ ///
+ /// Cycle time in days (started to completed).
+ ///
+ public double? CycleTimeDays { get; init; }
+
+ ///
+ /// Area path of the work item.
+ ///
+ public string? AreaPath { get; init; }
+
+ ///
+ /// Iteration path of the work item.
+ ///
+ public string? IterationPath { get; init; }
+}
diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs
new file mode 100644
index 0000000..5308ac9
--- /dev/null
+++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs
@@ -0,0 +1,463 @@
+namespace Viamus.Azure.Devops.Mcp.Server.Models;
+
+///
+/// Work in Progress (WIP) analysis results.
+///
+public sealed record WipAnalysisDto
+{
+ ///
+ /// Analysis timestamp.
+ ///
+ public DateTime AnalysisDate { get; init; } = DateTime.UtcNow;
+
+ ///
+ /// Total number of items in progress (not completed).
+ ///
+ public int TotalWip { get; init; }
+
+ ///
+ /// WIP breakdown by state.
+ ///
+ public List ByState { get; init; } = [];
+
+ ///
+ /// WIP breakdown by area path (team/squad).
+ ///
+ public List ByArea { get; init; } = [];
+
+ ///
+ /// WIP breakdown by assigned person.
+ ///
+ public List ByPerson { get; init; } = [];
+
+ ///
+ /// WIP breakdown by work item type.
+ ///
+ public Dictionary ByType { get; init; } = new();
+
+ ///
+ /// Items that have been in progress for too long (aging items).
+ ///
+ public List AgingItems { get; init; } = [];
+
+ ///
+ /// Summary insights about the WIP status.
+ ///
+ public List Insights { get; init; } = [];
+}
+
+///
+/// WIP count by state.
+///
+public sealed record WipByStateDto
+{
+ ///
+ /// State name.
+ ///
+ public string State { get; init; } = string.Empty;
+
+ ///
+ /// Number of items in this state.
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// Percentage of total WIP.
+ ///
+ public double Percentage { get; init; }
+
+ ///
+ /// Average age in days for items in this state.
+ ///
+ public double AverageAgeDays { get; init; }
+}
+
+///
+/// WIP count by area path (team/squad).
+///
+public sealed record WipByAreaDto
+{
+ ///
+ /// Area path.
+ ///
+ public string AreaPath { get; init; } = string.Empty;
+
+ ///
+ /// Number of items in this area.
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// Number of unique assignees in this area.
+ ///
+ public int UniqueAssignees { get; init; }
+
+ ///
+ /// Average items per person in this area.
+ ///
+ public double ItemsPerPerson { get; init; }
+
+ ///
+ /// Whether this area appears overloaded (high items per person).
+ ///
+ public bool IsOverloaded { get; init; }
+}
+
+///
+/// WIP count by person.
+///
+public sealed record WipByPersonDto
+{
+ ///
+ /// Person's display name.
+ ///
+ public string AssignedTo { get; init; } = string.Empty;
+
+ ///
+ /// Number of items assigned to this person.
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// Breakdown by state for this person.
+ ///
+ public Dictionary ByState { get; init; } = new();
+
+ ///
+ /// Whether this person appears overloaded.
+ ///
+ public bool IsOverloaded { get; init; }
+
+ ///
+ /// Oldest item age in days assigned to this person.
+ ///
+ public double OldestItemAgeDays { get; init; }
+}
+
+///
+/// Work item that has been in progress for too long.
+///
+public sealed record AgingWorkItemDto
+{
+ ///
+ /// Work item ID.
+ ///
+ public int Id { get; init; }
+
+ ///
+ /// Work item title.
+ ///
+ public string? Title { get; init; }
+
+ ///
+ /// Work item type.
+ ///
+ public string? WorkItemType { get; init; }
+
+ ///
+ /// Current state.
+ ///
+ public string? State { get; init; }
+
+ ///
+ /// Person assigned to this item.
+ ///
+ public string? AssignedTo { get; init; }
+
+ ///
+ /// Area path.
+ ///
+ public string? AreaPath { get; init; }
+
+ ///
+ /// Date when the item entered current state.
+ ///
+ public DateTime? StateChangedDate { get; init; }
+
+ ///
+ /// Days in current state.
+ ///
+ public double DaysInState { get; init; }
+
+ ///
+ /// Days since creation.
+ ///
+ public double DaysSinceCreation { get; init; }
+
+ ///
+ /// Priority of the item.
+ ///
+ public string? Priority { get; init; }
+
+ ///
+ /// Reason for aging classification.
+ ///
+ public string AgingReason { get; init; } = string.Empty;
+}
+
+///
+/// Bottleneck analysis results.
+///
+public sealed record BottleneckAnalysisDto
+{
+ ///
+ /// Analysis timestamp.
+ ///
+ public DateTime AnalysisDate { get; init; } = DateTime.UtcNow;
+
+ ///
+ /// Identified bottlenecks ordered by severity.
+ ///
+ public List Bottlenecks { get; init; } = [];
+
+ ///
+ /// Recommendations for addressing bottlenecks.
+ ///
+ public List Recommendations { get; init; } = [];
+}
+
+///
+/// A single bottleneck in the flow.
+///
+public sealed record BottleneckDto
+{
+ ///
+ /// Type of bottleneck (State, Person, Area, Type).
+ ///
+ public string Type { get; init; } = string.Empty;
+
+ ///
+ /// Name/identifier of the bottleneck location.
+ ///
+ public string Location { get; init; } = string.Empty;
+
+ ///
+ /// Number of items blocked/waiting.
+ ///
+ public int ItemCount { get; init; }
+
+ ///
+ /// Severity score (1-10).
+ ///
+ public int Severity { get; init; }
+
+ ///
+ /// Description of the bottleneck.
+ ///
+ public string Description { get; init; } = string.Empty;
+
+ ///
+ /// Sample work item IDs affected.
+ ///
+ public List SampleItemIds { get; init; } = [];
+}
+
+///
+/// Aging report analysis results.
+///
+public sealed record AgingReportDto
+{
+ ///
+ /// Analysis timestamp.
+ ///
+ public DateTime AnalysisDate { get; init; } = DateTime.UtcNow;
+
+ ///
+ /// Total number of aging items.
+ ///
+ public int TotalAgingItems { get; init; }
+
+ ///
+ /// Summary statistics about aging.
+ ///
+ public AgingSummaryDto Summary { get; init; } = new();
+
+ ///
+ /// Items grouped by urgency level.
+ ///
+ public AgingByUrgencyDto ByUrgency { get; init; } = new();
+
+ ///
+ /// Aging items grouped by state.
+ ///
+ public List ByState { get; init; } = [];
+
+ ///
+ /// Aging items grouped by assignee.
+ ///
+ public List ByAssignee { get; init; } = [];
+
+ ///
+ /// Aging items grouped by area.
+ ///
+ public List ByArea { get; init; } = [];
+
+ ///
+ /// Detailed list of aging items with recommendations.
+ ///
+ public List Items { get; init; } = [];
+
+ ///
+ /// Overall recommendations for addressing aging work.
+ ///
+ public List Recommendations { get; init; } = [];
+}
+
+///
+/// Summary statistics for aging analysis.
+///
+public sealed record AgingSummaryDto
+{
+ ///
+ /// Average age of all aging items in days.
+ ///
+ public double AverageAgeDays { get; init; }
+
+ ///
+ /// Median age in days.
+ ///
+ public double MedianAgeDays { get; init; }
+
+ ///
+ /// Maximum age in days.
+ ///
+ public double MaxAgeDays { get; init; }
+
+ ///
+ /// Percentage of total WIP that is aging.
+ ///
+ public double PercentageOfWip { get; init; }
+
+ ///
+ /// States with most aging items.
+ ///
+ public string TopAgingState { get; init; } = string.Empty;
+
+ ///
+ /// Person with most aging items.
+ ///
+ public string TopAgingAssignee { get; init; } = string.Empty;
+}
+
+///
+/// Aging items grouped by urgency level.
+///
+public sealed record AgingByUrgencyDto
+{
+ ///
+ /// Critical items (highest priority, very old).
+ ///
+ public List Critical { get; init; } = [];
+
+ ///
+ /// High urgency items.
+ ///
+ public List High { get; init; } = [];
+
+ ///
+ /// Medium urgency items.
+ ///
+ public List Medium { get; init; } = [];
+
+ ///
+ /// Low urgency items.
+ ///
+ public List Low { get; init; } = [];
+}
+
+///
+/// Aging statistics for a group (state, person, area).
+///
+public sealed record AgingByGroupDto
+{
+ ///
+ /// Group name (state name, person name, or area path).
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// Number of aging items in this group.
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// Average age in days for this group.
+ ///
+ public double AverageAgeDays { get; init; }
+
+ ///
+ /// Oldest item age in this group.
+ ///
+ public double MaxAgeDays { get; init; }
+
+ ///
+ /// List of work item IDs in this group.
+ ///
+ public List ItemIds { get; init; } = [];
+}
+
+///
+/// Detailed aging item with recommendation.
+///
+public sealed record AgingItemDetailDto
+{
+ ///
+ /// Work item ID.
+ ///
+ public int Id { get; init; }
+
+ ///
+ /// Work item title.
+ ///
+ public string? Title { get; init; }
+
+ ///
+ /// Work item type.
+ ///
+ public string? WorkItemType { get; init; }
+
+ ///
+ /// Current state.
+ ///
+ public string? State { get; init; }
+
+ ///
+ /// Assigned person.
+ ///
+ public string? AssignedTo { get; init; }
+
+ ///
+ /// Area path.
+ ///
+ public string? AreaPath { get; init; }
+
+ ///
+ /// Priority (1=highest).
+ ///
+ public string? Priority { get; init; }
+
+ ///
+ /// Days since last update.
+ ///
+ public double DaysSinceUpdate { get; init; }
+
+ ///
+ /// Days since creation.
+ ///
+ public double DaysSinceCreation { get; init; }
+
+ ///
+ /// Urgency classification (Critical, High, Medium, Low).
+ ///
+ public string Urgency { get; init; } = string.Empty;
+
+ ///
+ /// Urgency score (1-10, higher is more urgent).
+ ///
+ public int UrgencyScore { get; init; }
+
+ ///
+ /// Specific recommendation for this item.
+ ///
+ public string Recommendation { get; init; } = string.Empty;
+}
diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs
new file mode 100644
index 0000000..aa7ec6a
--- /dev/null
+++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs
@@ -0,0 +1,1334 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
+using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
+using Microsoft.VisualStudio.Services.Common;
+using Microsoft.VisualStudio.Services.WebApi;
+using ModelContextProtocol.Server;
+using Viamus.Azure.Devops.Mcp.Server.Configuration;
+using Viamus.Azure.Devops.Mcp.Server.Models;
+using Viamus.Azure.Devops.Mcp.Server.Services;
+
+namespace Viamus.Azure.Devops.Mcp.Server.Tools;
+
+///
+/// MCP tools for Azure DevOps Analytics operations.
+/// Provides flow metrics, throughput analysis, and delivery insights.
+///
+[McpServerToolType]
+public sealed class AnalyticsTools
+{
+ private readonly IAzureDevOpsService _azureDevOpsService;
+ private readonly AzureDevOpsOptions _options;
+ private readonly ILogger _logger;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ // States that indicate work has started
+ private static readonly HashSet InProgressStates = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Active", "In Progress", "Committed", "Doing", "In Development", "Development",
+ "In Review", "In Test", "Testing", "Code Review", "Resolved"
+ };
+
+ // States that indicate work is completed
+ private static readonly HashSet CompletedStates = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Done", "Closed", "Completed", "Resolved", "Removed"
+ };
+
+ public AnalyticsTools(
+ IAzureDevOpsService azureDevOpsService,
+ IOptions options,
+ ILogger logger)
+ {
+ _azureDevOpsService = azureDevOpsService;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ [McpServerTool(Name = "get_flow_metrics")]
+ [Description(@"Calculates flow metrics (Lead Time, Cycle Time, Throughput) for completed work items in a given period.
+
+Lead Time: Time from work item creation to completion (measures total delivery time)
+Cycle Time: Time from work started to completion (measures active work time)
+Throughput: Number of items completed per period
+
+Returns statistical analysis including average, median, percentiles (85th, 95th), and breakdown by type and period.
+Use this to answer questions like 'How fast are we delivering?' or 'Is our delivery speed improving?'")]
+ public async Task GetFlowMetrics(
+ [Description("The project name (required)")] string project,
+ [Description("Number of days to analyze (default: 30, max: 90)")] int daysBack = 30,
+ [Description("Work item types to include, comma-separated (e.g., 'Bug,User Story'). Leave empty for all types.")] string? workItemTypes = null,
+ [Description("Area path filter (optional, e.g., 'Project\\Team')")] string? areaPath = null,
+ [Description("Grouping period for throughput: 'day' or 'week' (default: week)")] string groupBy = "week",
+ [Description("Include individual work item details in response (default: false)")] bool includeItems = false,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ daysBack = Math.Clamp(daysBack, 1, 90);
+ var periodEnd = DateTime.UtcNow.Date;
+ var periodStart = periodEnd.AddDays(-daysBack);
+
+ _logger.LogInformation("Calculating flow metrics for project {Project} from {Start} to {End}",
+ project, periodStart, periodEnd);
+
+ // Build WIQL query for completed items
+ var typeFilter = BuildTypeFilter(workItemTypes);
+ var areaFilter = string.IsNullOrWhiteSpace(areaPath)
+ ? string.Empty
+ : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'";
+
+ // Query items that were completed (state changed to Done/Closed) in the period
+ // We look for items changed in the period and filter by completed state
+ var wiqlQuery = $@"
+ SELECT [System.Id]
+ FROM WorkItems
+ WHERE [System.TeamProject] = '{EscapeWiqlString(project)}'
+ AND [System.State] IN ('Done', 'Closed', 'Completed', 'Resolved')
+ AND [System.ChangedDate] >= '{periodStart:yyyy-MM-dd}'
+ AND [System.ChangedDate] <= '{periodEnd:yyyy-MM-dd}'{typeFilter}{areaFilter}
+ ORDER BY [System.ChangedDate] DESC";
+
+ var completedItems = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken);
+
+ if (completedItems.Count == 0)
+ {
+ return JsonSerializer.Serialize(new
+ {
+ message = "No completed work items found in the specified period",
+ periodStart,
+ periodEnd,
+ throughput = 0
+ }, JsonOptions);
+ }
+
+ // Get flow data for each item (including state change history)
+ var flowDataList = await GetFlowDataForItemsAsync(completedItems, project, periodStart, cancellationToken);
+
+ // Filter to only items actually completed in the period
+ var itemsInPeriod = flowDataList
+ .Where(f => f.CompletedDate >= periodStart && f.CompletedDate <= periodEnd)
+ .ToList();
+
+ if (itemsInPeriod.Count == 0)
+ {
+ return JsonSerializer.Serialize(new
+ {
+ message = "No work items were completed in the specified period (items may have been completed earlier)",
+ periodStart,
+ periodEnd,
+ throughput = 0
+ }, JsonOptions);
+ }
+
+ // Calculate metrics
+ var leadTimes = itemsInPeriod
+ .Where(f => f.LeadTimeDays.HasValue)
+ .Select(f => f.LeadTimeDays!.Value)
+ .ToList();
+
+ var cycleTimes = itemsInPeriod
+ .Where(f => f.CycleTimeDays.HasValue)
+ .Select(f => f.CycleTimeDays!.Value)
+ .ToList();
+
+ var throughputByType = itemsInPeriod
+ .GroupBy(f => f.WorkItemType ?? "Unknown")
+ .ToDictionary(g => g.Key, g => g.Count());
+
+ var throughputByPeriod = CalculateThroughputByPeriod(itemsInPeriod, periodStart, periodEnd, groupBy);
+
+ var result = new FlowMetricsDto
+ {
+ PeriodStart = periodStart,
+ PeriodEnd = periodEnd,
+ Throughput = itemsInPeriod.Count,
+ LeadTime = CalculateStatistics(leadTimes),
+ CycleTime = CalculateStatistics(cycleTimes),
+ ThroughputByType = throughputByType,
+ ThroughputByPeriod = throughputByPeriod,
+ Items = includeItems ? itemsInPeriod : []
+ };
+
+ return JsonSerializer.Serialize(result, JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error calculating flow metrics");
+ return JsonSerializer.Serialize(new { error = $"Error calculating flow metrics: {ex.Message}" }, JsonOptions);
+ }
+ }
+
+ [McpServerTool(Name = "compare_flow_metrics")]
+ [Description(@"Compares flow metrics between two periods to identify trends.
+Shows whether delivery is improving or degrading by comparing Lead Time, Cycle Time, and Throughput.
+Use this to answer 'Are we getting faster or slower?' or 'How does this sprint compare to last sprint?'")]
+ public async Task CompareFlowMetrics(
+ [Description("The project name (required)")] string project,
+ [Description("Days in each period to compare (default: 14)")] int periodDays = 14,
+ [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null,
+ [Description("Area path filter (optional)")] string? areaPath = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ periodDays = Math.Clamp(periodDays, 7, 45);
+
+ var currentEnd = DateTime.UtcNow.Date;
+ var currentStart = currentEnd.AddDays(-periodDays);
+ var previousEnd = currentStart.AddDays(-1);
+ var previousStart = previousEnd.AddDays(-periodDays);
+
+ // Get metrics for both periods
+ var currentMetricsJson = await GetFlowMetrics(project, periodDays, workItemTypes, areaPath, "week", false, cancellationToken);
+ var currentMetrics = JsonSerializer.Deserialize(currentMetricsJson, JsonOptions);
+
+ // Temporarily adjust to get previous period
+ var previousMetricsJson = await GetFlowMetricsForPeriod(project, previousStart, previousEnd, workItemTypes, areaPath, cancellationToken);
+ var previousMetrics = JsonSerializer.Deserialize(previousMetricsJson, JsonOptions);
+
+ var comparison = new
+ {
+ currentPeriod = new { start = currentStart, end = currentEnd },
+ previousPeriod = new { start = previousStart, end = previousEnd },
+ throughput = new
+ {
+ current = currentMetrics?.Throughput ?? 0,
+ previous = previousMetrics?.Throughput ?? 0,
+ change = (currentMetrics?.Throughput ?? 0) - (previousMetrics?.Throughput ?? 0),
+ changePercent = CalculateChangePercent(previousMetrics?.Throughput ?? 0, currentMetrics?.Throughput ?? 0),
+ trend = GetTrend((previousMetrics?.Throughput ?? 0), (currentMetrics?.Throughput ?? 0), true)
+ },
+ leadTime = new
+ {
+ currentMedian = currentMetrics?.LeadTime.Median ?? 0,
+ previousMedian = previousMetrics?.LeadTime.Median ?? 0,
+ changePercent = CalculateChangePercent(previousMetrics?.LeadTime.Median ?? 0, currentMetrics?.LeadTime.Median ?? 0),
+ trend = GetTrend(previousMetrics?.LeadTime.Median ?? 0, currentMetrics?.LeadTime.Median ?? 0, false)
+ },
+ cycleTime = new
+ {
+ currentMedian = currentMetrics?.CycleTime.Median ?? 0,
+ previousMedian = previousMetrics?.CycleTime.Median ?? 0,
+ changePercent = CalculateChangePercent(previousMetrics?.CycleTime.Median ?? 0, currentMetrics?.CycleTime.Median ?? 0),
+ trend = GetTrend(previousMetrics?.CycleTime.Median ?? 0, currentMetrics?.CycleTime.Median ?? 0, false)
+ },
+ summary = GenerateComparisonSummary(currentMetrics, previousMetrics)
+ };
+
+ return JsonSerializer.Serialize(comparison, JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error comparing flow metrics");
+ return JsonSerializer.Serialize(new { error = $"Error comparing flow metrics: {ex.Message}" }, JsonOptions);
+ }
+ }
+
+ private async Task GetFlowMetricsForPeriod(
+ string project,
+ DateTime periodStart,
+ DateTime periodEnd,
+ string? workItemTypes,
+ string? areaPath,
+ CancellationToken cancellationToken)
+ {
+ var typeFilter = BuildTypeFilter(workItemTypes);
+ var areaFilter = string.IsNullOrWhiteSpace(areaPath)
+ ? string.Empty
+ : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'";
+
+ var wiqlQuery = $@"
+ SELECT [System.Id]
+ FROM WorkItems
+ WHERE [System.TeamProject] = '{EscapeWiqlString(project)}'
+ AND [System.State] IN ('Done', 'Closed', 'Completed', 'Resolved')
+ AND [System.ChangedDate] >= '{periodStart:yyyy-MM-dd}'
+ AND [System.ChangedDate] <= '{periodEnd:yyyy-MM-dd}'{typeFilter}{areaFilter}
+ ORDER BY [System.ChangedDate] DESC";
+
+ var completedItems = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken);
+
+ if (completedItems.Count == 0)
+ {
+ return JsonSerializer.Serialize(new FlowMetricsDto
+ {
+ PeriodStart = periodStart,
+ PeriodEnd = periodEnd,
+ Throughput = 0
+ }, JsonOptions);
+ }
+
+ var flowDataList = await GetFlowDataForItemsAsync(completedItems, project, periodStart, cancellationToken);
+ var itemsInPeriod = flowDataList
+ .Where(f => f.CompletedDate >= periodStart && f.CompletedDate <= periodEnd)
+ .ToList();
+
+ var leadTimes = itemsInPeriod.Where(f => f.LeadTimeDays.HasValue).Select(f => f.LeadTimeDays!.Value).ToList();
+ var cycleTimes = itemsInPeriod.Where(f => f.CycleTimeDays.HasValue).Select(f => f.CycleTimeDays!.Value).ToList();
+
+ return JsonSerializer.Serialize(new FlowMetricsDto
+ {
+ PeriodStart = periodStart,
+ PeriodEnd = periodEnd,
+ Throughput = itemsInPeriod.Count,
+ LeadTime = CalculateStatistics(leadTimes),
+ CycleTime = CalculateStatistics(cycleTimes)
+ }, JsonOptions);
+ }
+
+ private async Task> GetFlowDataForItemsAsync(
+ IReadOnlyList items,
+ string project,
+ DateTime periodStart,
+ CancellationToken cancellationToken)
+ {
+ var flowDataList = new List();
+
+ // Create connection for revision queries
+ var credentials = new VssBasicCredential(string.Empty, _options.PersonalAccessToken);
+ using var connection = new VssConnection(new Uri(_options.OrganizationUrl), credentials);
+ using var witClient = connection.GetClient();
+
+ foreach (var item in items)
+ {
+ try
+ {
+ // Get work item revisions to find state transitions
+ var revisions = await witClient.GetRevisionsAsync(
+ project: project ?? _options.DefaultProject,
+ id: item.Id,
+ cancellationToken: cancellationToken);
+
+ DateTime? startedDate = null;
+ DateTime? completedDate = null;
+
+ // Find when the item first entered an "in progress" state
+ // and when it first entered a "completed" state
+ foreach (var revision in revisions.OrderBy(r => r.Rev))
+ {
+ if (revision.Fields.TryGetValue("System.State", out var stateObj) &&
+ revision.Fields.TryGetValue("System.ChangedDate", out var dateObj))
+ {
+ var state = stateObj?.ToString();
+ var changedDate = dateObj is DateTime dt ? dt : DateTime.TryParse(dateObj?.ToString(), out var parsed) ? parsed : (DateTime?)null;
+
+ if (state != null && changedDate.HasValue)
+ {
+ if (startedDate == null && InProgressStates.Contains(state))
+ {
+ startedDate = changedDate.Value;
+ }
+
+ if (CompletedStates.Contains(state))
+ {
+ completedDate = changedDate.Value;
+ }
+ }
+ }
+ }
+
+ // Calculate times
+ double? leadTimeDays = null;
+ double? cycleTimeDays = null;
+
+ if (item.CreatedDate.HasValue && completedDate.HasValue)
+ {
+ leadTimeDays = (completedDate.Value - item.CreatedDate.Value).TotalDays;
+ }
+
+ if (startedDate.HasValue && completedDate.HasValue)
+ {
+ cycleTimeDays = (completedDate.Value - startedDate.Value).TotalDays;
+ }
+
+ flowDataList.Add(new WorkItemFlowDataDto
+ {
+ Id = item.Id,
+ Title = item.Title,
+ WorkItemType = item.WorkItemType,
+ CreatedDate = item.CreatedDate,
+ StartedDate = startedDate,
+ CompletedDate = completedDate,
+ LeadTimeDays = leadTimeDays,
+ CycleTimeDays = cycleTimeDays,
+ AreaPath = item.AreaPath,
+ IterationPath = item.IterationPath
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get revisions for work item {WorkItemId}", item.Id);
+ // Add item with basic data only
+ flowDataList.Add(new WorkItemFlowDataDto
+ {
+ Id = item.Id,
+ Title = item.Title,
+ WorkItemType = item.WorkItemType,
+ CreatedDate = item.CreatedDate,
+ AreaPath = item.AreaPath,
+ IterationPath = item.IterationPath
+ });
+ }
+ }
+
+ return flowDataList;
+ }
+
+ private static MetricStatistics CalculateStatistics(List values)
+ {
+ if (values.Count == 0)
+ {
+ return new MetricStatistics { Count = 0 };
+ }
+
+ var sorted = values.OrderBy(v => v).ToList();
+ var count = sorted.Count;
+ var sum = sorted.Sum();
+ var average = sum / count;
+
+ var squaredDiffs = sorted.Select(v => Math.Pow(v - average, 2)).Sum();
+ var stdDev = Math.Sqrt(squaredDiffs / count);
+
+ return new MetricStatistics
+ {
+ Average = Math.Round(average, 2),
+ Median = Math.Round(GetPercentile(sorted, 50), 2),
+ Percentile85 = Math.Round(GetPercentile(sorted, 85), 2),
+ Percentile95 = Math.Round(GetPercentile(sorted, 95), 2),
+ Min = Math.Round(sorted.First(), 2),
+ Max = Math.Round(sorted.Last(), 2),
+ StdDev = Math.Round(stdDev, 2),
+ Count = count
+ };
+ }
+
+ private static double GetPercentile(List sortedValues, double percentile)
+ {
+ if (sortedValues.Count == 0) return 0;
+ if (sortedValues.Count == 1) return sortedValues[0];
+
+ var index = (percentile / 100.0) * (sortedValues.Count - 1);
+ var lower = (int)Math.Floor(index);
+ var upper = (int)Math.Ceiling(index);
+
+ if (lower == upper) return sortedValues[lower];
+
+ var fraction = index - lower;
+ return sortedValues[lower] + fraction * (sortedValues[upper] - sortedValues[lower]);
+ }
+
+ private static List CalculateThroughputByPeriod(
+ List items,
+ DateTime periodStart,
+ DateTime periodEnd,
+ string groupBy)
+ {
+ var periods = new List();
+ var isWeekly = groupBy.Equals("week", StringComparison.OrdinalIgnoreCase);
+ var periodLength = isWeekly ? 7 : 1;
+
+ var currentStart = periodStart;
+ while (currentStart < periodEnd)
+ {
+ var currentEnd = currentStart.AddDays(periodLength);
+ if (currentEnd > periodEnd) currentEnd = periodEnd;
+
+ var count = items.Count(i =>
+ i.CompletedDate >= currentStart && i.CompletedDate < currentEnd);
+
+ periods.Add(new ThroughputPeriod
+ {
+ PeriodStart = currentStart,
+ PeriodEnd = currentEnd,
+ Count = count
+ });
+
+ currentStart = currentEnd;
+ }
+
+ return periods;
+ }
+
+ private static double CalculateChangePercent(double previous, double current)
+ {
+ if (previous == 0) return current > 0 ? 100 : 0;
+ return Math.Round(((current - previous) / previous) * 100, 1);
+ }
+
+ private static string GetTrend(double previous, double current, bool higherIsBetter)
+ {
+ if (Math.Abs(current - previous) < 0.01) return "stable";
+
+ var isHigher = current > previous;
+ if (higherIsBetter)
+ {
+ return isHigher ? "improving" : "degrading";
+ }
+ else
+ {
+ return isHigher ? "degrading" : "improving";
+ }
+ }
+
+ private static string GenerateComparisonSummary(FlowMetricsDto? current, FlowMetricsDto? previous)
+ {
+ if (current == null || previous == null) return "Insufficient data for comparison";
+
+ var insights = new List();
+
+ // Throughput analysis
+ if (current.Throughput > previous.Throughput)
+ {
+ insights.Add($"Throughput increased by {current.Throughput - previous.Throughput} items ({CalculateChangePercent(previous.Throughput, current.Throughput):+0.#}%)");
+ }
+ else if (current.Throughput < previous.Throughput)
+ {
+ insights.Add($"Throughput decreased by {previous.Throughput - current.Throughput} items ({CalculateChangePercent(previous.Throughput, current.Throughput):0.#}%)");
+ }
+
+ // Lead time analysis
+ if (current.LeadTime.Median < previous.LeadTime.Median)
+ {
+ insights.Add($"Lead time improved (median: {previous.LeadTime.Median:0.#}d -> {current.LeadTime.Median:0.#}d)");
+ }
+ else if (current.LeadTime.Median > previous.LeadTime.Median)
+ {
+ insights.Add($"Lead time degraded (median: {previous.LeadTime.Median:0.#}d -> {current.LeadTime.Median:0.#}d)");
+ }
+
+ // Cycle time analysis
+ if (current.CycleTime.Median < previous.CycleTime.Median)
+ {
+ insights.Add($"Cycle time improved (median: {previous.CycleTime.Median:0.#}d -> {current.CycleTime.Median:0.#}d)");
+ }
+ else if (current.CycleTime.Median > previous.CycleTime.Median)
+ {
+ insights.Add($"Cycle time degraded (median: {previous.CycleTime.Median:0.#}d -> {current.CycleTime.Median:0.#}d)");
+ }
+
+ return insights.Count > 0 ? string.Join(". ", insights) : "Metrics are stable between periods";
+ }
+
+ private static string BuildTypeFilter(string? workItemTypes)
+ {
+ if (string.IsNullOrWhiteSpace(workItemTypes))
+ {
+ return string.Empty;
+ }
+
+ var types = workItemTypes
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(t => $"'{EscapeWiqlString(t)}'");
+
+ return $" AND [System.WorkItemType] IN ({string.Join(", ", types)})";
+ }
+
+ private static string EscapeWiqlString(string value)
+ {
+ return value.Replace("'", "''");
+ }
+
+ #region WIP Analysis
+
+ [McpServerTool(Name = "get_wip_analysis")]
+ [Description(@"Analyzes Work in Progress (WIP) to identify bottlenecks and overload.
+
+Returns:
+- Total WIP count
+- WIP breakdown by state (shows where work is accumulating)
+- WIP by area/team (shows which teams are overloaded)
+- WIP by person (shows who has too much on their plate)
+- Aging items (work that's been stuck too long)
+
+Use this to answer 'Where is work piling up?', 'Who is overloaded?', or 'What needs attention?'")]
+ public async Task GetWipAnalysis(
+ [Description("The project name (required)")] string project,
+ [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null,
+ [Description("Area path filter (optional)")] string? areaPath = null,
+ [Description("Threshold in days to consider an item as aging (default: 14)")] int agingThresholdDays = 14,
+ [Description("Maximum items per person before flagging as overloaded (default: 5)")] int overloadThreshold = 5,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Analyzing WIP for project {Project}", project);
+
+ // Query active/in-progress items (not completed)
+ var typeFilter = BuildTypeFilter(workItemTypes);
+ var areaFilter = string.IsNullOrWhiteSpace(areaPath)
+ ? string.Empty
+ : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'";
+
+ var wiqlQuery = $@"
+ SELECT [System.Id]
+ FROM WorkItems
+ WHERE [System.TeamProject] = '{EscapeWiqlString(project)}'
+ AND [System.State] NOT IN ('Done', 'Closed', 'Completed', 'Removed', 'Cut')
+ {typeFilter}{areaFilter}
+ ORDER BY [System.ChangedDate] DESC";
+
+ var wipItems = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken);
+
+ if (wipItems.Count == 0)
+ {
+ return JsonSerializer.Serialize(new
+ {
+ message = "No work items in progress found",
+ totalWip = 0
+ }, JsonOptions);
+ }
+
+ var now = DateTime.UtcNow;
+
+ // Calculate WIP by state
+ var byState = wipItems
+ .GroupBy(w => w.State ?? "Unknown")
+ .Select(g => new WipByStateDto
+ {
+ State = g.Key,
+ Count = g.Count(),
+ Percentage = Math.Round((double)g.Count() / wipItems.Count * 100, 1),
+ AverageAgeDays = Math.Round(g.Average(w =>
+ w.ChangedDate.HasValue ? (now - w.ChangedDate.Value).TotalDays : 0), 1)
+ })
+ .OrderByDescending(s => s.Count)
+ .ToList();
+
+ // Calculate WIP by area
+ var byArea = wipItems
+ .GroupBy(w => GetLastAreaSegment(w.AreaPath))
+ .Select(g =>
+ {
+ var uniqueAssignees = g.Select(w => w.AssignedTo).Where(a => !string.IsNullOrEmpty(a)).Distinct().Count();
+ var itemsPerPerson = uniqueAssignees > 0 ? (double)g.Count() / uniqueAssignees : g.Count();
+ return new WipByAreaDto
+ {
+ AreaPath = g.Key,
+ Count = g.Count(),
+ UniqueAssignees = uniqueAssignees,
+ ItemsPerPerson = Math.Round(itemsPerPerson, 1),
+ IsOverloaded = itemsPerPerson > overloadThreshold
+ };
+ })
+ .OrderByDescending(a => a.Count)
+ .ToList();
+
+ // Calculate WIP by person
+ var byPerson = wipItems
+ .Where(w => !string.IsNullOrEmpty(w.AssignedTo))
+ .GroupBy(w => w.AssignedTo!)
+ .Select(g =>
+ {
+ var stateBreakdown = g.GroupBy(w => w.State ?? "Unknown")
+ .ToDictionary(sg => sg.Key, sg => sg.Count());
+ var oldestAge = g.Max(w => w.ChangedDate.HasValue ? (now - w.ChangedDate.Value).TotalDays : 0);
+ return new WipByPersonDto
+ {
+ AssignedTo = g.Key,
+ Count = g.Count(),
+ ByState = stateBreakdown,
+ IsOverloaded = g.Count() > overloadThreshold,
+ OldestItemAgeDays = Math.Round(oldestAge, 1)
+ };
+ })
+ .OrderByDescending(p => p.Count)
+ .ToList();
+
+ // Calculate WIP by type
+ var byType = wipItems
+ .GroupBy(w => w.WorkItemType ?? "Unknown")
+ .ToDictionary(g => g.Key, g => g.Count());
+
+ // Find aging items
+ var agingItems = wipItems
+ .Where(w => w.ChangedDate.HasValue && (now - w.ChangedDate.Value).TotalDays > agingThresholdDays)
+ .Select(w => new AgingWorkItemDto
+ {
+ Id = w.Id,
+ Title = w.Title,
+ WorkItemType = w.WorkItemType,
+ State = w.State,
+ AssignedTo = w.AssignedTo,
+ AreaPath = w.AreaPath,
+ StateChangedDate = w.ChangedDate,
+ DaysInState = Math.Round((now - w.ChangedDate!.Value).TotalDays, 1),
+ DaysSinceCreation = w.CreatedDate.HasValue
+ ? Math.Round((now - w.CreatedDate.Value).TotalDays, 1)
+ : 0,
+ Priority = w.Priority,
+ AgingReason = $"No updates for {Math.Round((now - w.ChangedDate!.Value).TotalDays, 0)} days"
+ })
+ .OrderByDescending(a => a.DaysInState)
+ .Take(20)
+ .ToList();
+
+ // Generate insights
+ var insights = GenerateWipInsights(wipItems.Count, byState, byPerson, byArea, agingItems, overloadThreshold);
+
+ var result = new WipAnalysisDto
+ {
+ AnalysisDate = now,
+ TotalWip = wipItems.Count,
+ ByState = byState,
+ ByArea = byArea,
+ ByPerson = byPerson,
+ ByType = byType,
+ AgingItems = agingItems,
+ Insights = insights
+ };
+
+ return JsonSerializer.Serialize(result, JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error analyzing WIP");
+ return JsonSerializer.Serialize(new { error = $"Error analyzing WIP: {ex.Message}" }, JsonOptions);
+ }
+ }
+
+ [McpServerTool(Name = "get_bottlenecks")]
+ [Description(@"Identifies bottlenecks in the workflow by analyzing where work is accumulating.
+
+Analyzes:
+- States with abnormally high item counts
+- People with too many assignments
+- Areas/teams with work piling up
+- Long-running items blocking flow
+
+Returns prioritized list of bottlenecks with recommendations.
+Use this to answer 'Where is our flow blocked?' or 'What's causing delays?'")]
+ public async Task GetBottlenecks(
+ [Description("The project name (required)")] string project,
+ [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null,
+ [Description("Area path filter (optional)")] string? areaPath = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Identifying bottlenecks for project {Project}", project);
+
+ // Get WIP data first
+ var wipJson = await GetWipAnalysis(project, workItemTypes, areaPath, 14, 5, cancellationToken);
+ var wipData = JsonSerializer.Deserialize(wipJson, JsonOptions);
+
+ if (wipData == null || wipData.TotalWip == 0)
+ {
+ return JsonSerializer.Serialize(new
+ {
+ message = "No work in progress found - no bottlenecks to analyze",
+ bottlenecks = Array.Empty