From ccf90c259620202cabc94d06d2e441692c12af3a Mon Sep 17 00:00:00 2001 From: viamu Date: Tue, 7 Jul 2026 10:33:25 -0300 Subject: [PATCH] feat: support multiple azure devops organizations --- .env.example | 30 ++ README.md | 41 +- docker-compose.yml | 9 + install-mcp-azure-devops.ps1 | 7 + .../Configuration/AzureDevOpsOptions.cs | 128 +++++- src/Viamus.Azure.Devops.Mcp.Server/Program.cs | 11 +- .../AzureDevOpsOrganizationContextAccessor.cs | 37 ++ .../Services/AzureDevOpsService.cs | 363 +++++++++++++----- ...IAzureDevOpsOrganizationContextAccessor.cs | 17 + .../Tools/GitTools.cs | 21 + .../Tools/PipelineTools.cs | 27 ++ .../Tools/PullRequestTools.cs | 31 ++ .../Tools/WikiTools.cs | 17 + .../Tools/WorkItemTools.cs | 41 +- .../appsettings.json | 4 +- .../Configuration/AzureDevOpsOptionsTests.cs | 76 ++++ 16 files changed, 750 insertions(+), 110 deletions(-) create mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsOrganizationContextAccessor.cs create mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsOrganizationContextAccessor.cs create mode 100644 tests/Viamus.Azure.Devops.Mcp.Server.Tests/Configuration/AzureDevOpsOptionsTests.cs diff --git a/.env.example b/.env.example index 9682165..394be0a 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,36 @@ AZURE_DEVOPS_PAT=your-personal-access-token # AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name +# -------------------------------------------- +# OPTIONAL: Multiple Organizations / PATs +# -------------------------------------------- +# Use these when one MCP server must access multiple Azure DevOps +# organizations with different PATs. The default organization is used when +# a tool call does not include the optional "organization" argument. +# +# For appsettings.json, use AzureDevOps:Organizations as an array of objects. +# For Docker environment variables, fill the indexed slots below or add more +# AzureDevOps__Organizations__{n}__* entries in docker-compose.yml. +# +# Example values: +# AZURE_DEVOPS_DEFAULT_ORGANIZATION=primary +# AZURE_DEVOPS_ORGANIZATION_0_NAME=primary +# AZURE_DEVOPS_ORGANIZATION_0_URL=https://dev.azure.com/your-primary-organization +# AZURE_DEVOPS_ORGANIZATION_0_PAT=your-primary-personal-access-token +# AZURE_DEVOPS_ORGANIZATION_0_DEFAULT_PROJECT=your-primary-project +# +AZURE_DEVOPS_DEFAULT_ORGANIZATION= + +AZURE_DEVOPS_ORGANIZATION_0_NAME= +AZURE_DEVOPS_ORGANIZATION_0_URL= +AZURE_DEVOPS_ORGANIZATION_0_PAT= +AZURE_DEVOPS_ORGANIZATION_0_DEFAULT_PROJECT= + +AZURE_DEVOPS_ORGANIZATION_1_NAME= +AZURE_DEVOPS_ORGANIZATION_1_URL= +AZURE_DEVOPS_ORGANIZATION_1_PAT= +AZURE_DEVOPS_ORGANIZATION_1_DEFAULT_PROJECT= + # -------------------------------------------- # OPTIONAL: API Key Authentication # -------------------------------------------- diff --git a/README.md b/README.md index c7fd7a7..8c7f524 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,30 @@ Edit `src/Viamus.Azure.Devops.Mcp.Server/appsettings.json` with your Azure DevOp } ``` +For multiple Azure DevOps organizations or PATs, keep one entry per organization and select it in tool calls with the optional `organization` argument: + +```json +{ + "AzureDevOps": { + "DefaultOrganization": "primary", + "Organizations": [ + { + "Name": "primary", + "OrganizationUrl": "https://dev.azure.com/primary-org", + "PersonalAccessToken": "primary-pat", + "DefaultProject": "primary-project" + }, + { + "Name": "secondary", + "OrganizationUrl": "https://dev.azure.com/secondary-org", + "PersonalAccessToken": "secondary-pat", + "DefaultProject": "secondary-project" + } + ] + } +} +``` + > **Need a PAT?** See [Creating a Personal Access Token](#creating-a-personal-access-token-pat) below. ### 2. Run the server @@ -96,6 +120,8 @@ The script will automatically: - Configure your Azure DevOps credentials - Register the MCP server with Claude Code (HTTPS transport) +For multiple organizations/PATs, run the installer for the primary organization, then add `AzureDevOps:Organizations` entries to `appsettings.json` or use the Docker environment variables from `.env.example`. + After installation, start the server: ```powershell cd $env:USERPROFILE\mcp-azure-devops @@ -198,6 +224,16 @@ This project implements an MCP server that exposes tools for querying and managi 5. Click **Create** and **copy the token immediately** (you won't see it again!) +### Multiple Organizations / PATs + +The server supports one PAT per configured Azure DevOps organization. Existing single-organization settings still work: + +- `AzureDevOps:OrganizationUrl` +- `AzureDevOps:PersonalAccessToken` +- `AzureDevOps:DefaultProject` + +For multiple PATs, configure `AzureDevOps:Organizations` with `Name`, `OrganizationUrl`, `PersonalAccessToken`, and optional `DefaultProject`. Set `AzureDevOps:DefaultOrganization` to choose the fallback organization. Tool calls can pass `organization` as the configured `Name`, the full organization URL, or the organization slug from `https://dev.azure.com/{slug}`. + --- ## Running Options @@ -212,6 +248,8 @@ Best for: Production use, quick setup without .NET installed # Edit .env with your Azure DevOps credentials ``` + For multiple organizations/PATs, fill `AZURE_DEVOPS_ORGANIZATION_0_*`, `AZURE_DEVOPS_ORGANIZATION_1_*`, and `AZURE_DEVOPS_DEFAULT_ORGANIZATION` in `.env`. + 2. Start the server: ```bash docker compose up -d @@ -447,6 +485,7 @@ After configuring the MCP client, you can ask questions like: 2. Check PAT hasn't expired in Azure DevOps 3. Ensure PAT has required scopes (Work Items, Code, Build) 4. Verify the organization URL is correct (no trailing slash) +5. If using multiple organizations, verify the tool call's `organization` value matches a configured `Name`, URL, or Azure DevOps organization slug
@@ -473,7 +512,7 @@ curl -H "X-API-Key: your-key" http://localhost:5000 1. Verify `AZURE_DEVOPS_DEFAULT_PROJECT` matches exact project name 2. Or pass the project name explicitly in your queries -3. Check PAT has access to the project +3. Check the selected organization's PAT has access to the project
diff --git a/docker-compose.yml b/docker-compose.yml index 8f0f547..02b37e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,15 @@ services: - AzureDevOps__OrganizationUrl=${AZURE_DEVOPS_ORG_URL} - AzureDevOps__PersonalAccessToken=${AZURE_DEVOPS_PAT} - AzureDevOps__DefaultProject=${AZURE_DEVOPS_DEFAULT_PROJECT:-} + - AzureDevOps__DefaultOrganization=${AZURE_DEVOPS_DEFAULT_ORGANIZATION:-} + - AzureDevOps__Organizations__0__Name=${AZURE_DEVOPS_ORGANIZATION_0_NAME:-} + - AzureDevOps__Organizations__0__OrganizationUrl=${AZURE_DEVOPS_ORGANIZATION_0_URL:-} + - AzureDevOps__Organizations__0__PersonalAccessToken=${AZURE_DEVOPS_ORGANIZATION_0_PAT:-} + - AzureDevOps__Organizations__0__DefaultProject=${AZURE_DEVOPS_ORGANIZATION_0_DEFAULT_PROJECT:-} + - AzureDevOps__Organizations__1__Name=${AZURE_DEVOPS_ORGANIZATION_1_NAME:-} + - AzureDevOps__Organizations__1__OrganizationUrl=${AZURE_DEVOPS_ORGANIZATION_1_URL:-} + - AzureDevOps__Organizations__1__PersonalAccessToken=${AZURE_DEVOPS_ORGANIZATION_1_PAT:-} + - AzureDevOps__Organizations__1__DefaultProject=${AZURE_DEVOPS_ORGANIZATION_1_DEFAULT_PROJECT:-} - ServerSecurity__ApiKey=${MCP_API_KEY:-} - ServerSecurity__RequireApiKey=${MCP_REQUIRE_API_KEY:-false} restart: unless-stopped diff --git a/install-mcp-azure-devops.ps1 b/install-mcp-azure-devops.ps1 index caa33c9..39428de 100644 --- a/install-mcp-azure-devops.ps1 +++ b/install-mcp-azure-devops.ps1 @@ -25,6 +25,9 @@ .PARAMETER DefaultProject Your default Azure DevOps project name (optional) +.PARAMETER DefaultOrganization + Your default Azure DevOps organization alias when multiple organizations are configured (optional) + .PARAMETER ApiKey API key for MCP server authentication (optional). If provided, RequireApiKey is automatically enabled. @@ -49,6 +52,8 @@ param( [string]$DefaultProject = "", + [string]$DefaultOrganization = "", + [string]$ApiKey = "", [bool]$RequireApiKey = $false @@ -218,6 +223,8 @@ $appSettings = @{ OrganizationUrl = $OrganizationUrl PersonalAccessToken = $PersonalAccessToken DefaultProject = $DefaultProject + DefaultOrganization = $DefaultOrganization + Organizations = @() } ServerSecurity = @{ ApiKey = $ApiKey diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Configuration/AzureDevOpsOptions.cs b/src/Viamus.Azure.Devops.Mcp.Server/Configuration/AzureDevOpsOptions.cs index 7ea8b17..b61b625 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Configuration/AzureDevOpsOptions.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Configuration/AzureDevOpsOptions.cs @@ -7,18 +7,140 @@ public sealed class AzureDevOpsOptions { public const string SectionName = "AzureDevOps"; + /// + /// The default Azure DevOps organization URL (e.g., https://dev.azure.com/your-org). + /// Kept for backward compatibility with single-organization configuration. + /// + public string? OrganizationUrl { get; set; } + + /// + /// Personal Access Token (PAT) for the default organization. + /// Kept for backward compatibility with single-organization configuration. + /// + public string? PersonalAccessToken { get; set; } + + /// + /// Default project name (optional). + /// + public string? DefaultProject { get; set; } + + /// + /// Default organization name or URL when multiple organizations are configured. + /// + public string? DefaultOrganization { get; set; } + + /// + /// Additional Azure DevOps organizations, each with its own PAT. + /// + public IList Organizations { get; set; } = []; + + /// + /// Gets all configured organizations, including the backward-compatible root settings. + /// + public IReadOnlyList GetConfiguredOrganizations() + { + var organizations = new List(); + + if (!string.IsNullOrWhiteSpace(OrganizationUrl) || !string.IsNullOrWhiteSpace(PersonalAccessToken)) + { + organizations.Add(new AzureDevOpsOrganizationOptions + { + Name = GetOrganizationNameFromUrl(OrganizationUrl) ?? "default", + OrganizationUrl = OrganizationUrl, + PersonalAccessToken = PersonalAccessToken, + DefaultProject = DefaultProject + }); + } + + organizations.AddRange(Organizations.Where(o => + !string.IsNullOrWhiteSpace(o.Name) || + !string.IsNullOrWhiteSpace(o.OrganizationUrl) || + !string.IsNullOrWhiteSpace(o.PersonalAccessToken))); + + return organizations; + } + + /// + /// Validates the Azure DevOps configuration. + /// + public IReadOnlyList Validate() + { + var errors = new List(); + var organizations = GetConfiguredOrganizations(); + + if (organizations.Count == 0) + { + errors.Add("Configure AzureDevOps:OrganizationUrl and AzureDevOps:PersonalAccessToken, or at least one AzureDevOps:Organizations entry."); + return errors; + } + + foreach (var organization in organizations) + { + var displayName = string.IsNullOrWhiteSpace(organization.Name) + ? organization.OrganizationUrl ?? "(unnamed organization)" + : organization.Name; + + if (string.IsNullOrWhiteSpace(organization.OrganizationUrl)) + { + errors.Add($"Azure DevOps organization '{displayName}' requires OrganizationUrl."); + } + + if (string.IsNullOrWhiteSpace(organization.PersonalAccessToken)) + { + errors.Add($"Azure DevOps organization '{displayName}' requires PersonalAccessToken."); + } + } + + return errors; + } + + private static string? GetOrganizationNameFromUrl(string? organizationUrl) + { + if (string.IsNullOrWhiteSpace(organizationUrl) || + !Uri.TryCreate(organizationUrl, UriKind.Absolute, out var uri)) + { + return null; + } + + if (uri.Host.Equals("dev.azure.com", StringComparison.OrdinalIgnoreCase)) + { + return uri.Segments + .Select(segment => segment.Trim('/')) + .FirstOrDefault(segment => !string.IsNullOrWhiteSpace(segment)); + } + + const string visualStudioSuffix = ".visualstudio.com"; + if (uri.Host.EndsWith(visualStudioSuffix, StringComparison.OrdinalIgnoreCase)) + { + return uri.Host[..^visualStudioSuffix.Length]; + } + + return uri.Host; + } +} + +/// +/// Configuration options for one Azure DevOps organization. +/// +public sealed class AzureDevOpsOrganizationOptions +{ + /// + /// Friendly organization alias used by tool calls. + /// + public string? Name { get; set; } + /// /// The Azure DevOps organization URL (e.g., https://dev.azure.com/your-org). /// - public required string OrganizationUrl { get; set; } + public string? OrganizationUrl { get; set; } /// /// Personal Access Token (PAT) for authentication. /// - public required string PersonalAccessToken { get; set; } + public string? PersonalAccessToken { get; set; } /// - /// Default project name (optional). + /// Default project name for this organization (optional). /// public string? DefaultProject { get; set; } } diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Program.cs b/src/Viamus.Azure.Devops.Mcp.Server/Program.cs index 3a25b2e..4945c05 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Program.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Program.cs @@ -15,16 +15,15 @@ // Validate configuration on startup var azureDevOpsConfig = builder.Configuration.GetSection(AzureDevOpsOptions.SectionName).Get(); -if (string.IsNullOrWhiteSpace(azureDevOpsConfig?.OrganizationUrl)) +var azureDevOpsValidationErrors = azureDevOpsConfig?.Validate() ?? ["AzureDevOps configuration is required."]; +if (azureDevOpsValidationErrors.Count > 0) { - throw new InvalidOperationException("AzureDevOps:OrganizationUrl configuration is required."); -} -if (string.IsNullOrWhiteSpace(azureDevOpsConfig?.PersonalAccessToken)) -{ - throw new InvalidOperationException("AzureDevOps:PersonalAccessToken configuration is required."); + throw new InvalidOperationException( + "AzureDevOps configuration is invalid: " + string.Join(" ", azureDevOpsValidationErrors)); } // Register services +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Configure MCP Server diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsOrganizationContextAccessor.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsOrganizationContextAccessor.cs new file mode 100644 index 0000000..c1f8e3d --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsOrganizationContextAccessor.cs @@ -0,0 +1,37 @@ +namespace Viamus.Azure.Devops.Mcp.Server.Services; + +/// +/// Async-local implementation for selecting an Azure DevOps organization per tool call. +/// +public sealed class AzureDevOpsOrganizationContextAccessor : IAzureDevOpsOrganizationContextAccessor +{ + private readonly AsyncLocal _currentOrganization = new(); + + public string? CurrentOrganization => _currentOrganization.Value; + + public IDisposable Use(string? organization) + { + var previous = _currentOrganization.Value; + _currentOrganization.Value = string.IsNullOrWhiteSpace(organization) + ? null + : organization.Trim(); + + return new Scope(this, previous); + } + + private sealed class Scope(AzureDevOpsOrganizationContextAccessor accessor, string? previous) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + { + return; + } + + accessor._currentOrganization.Value = previous; + _disposed = true; + } + } +} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index 796e2b5..5215a1f 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -20,13 +20,18 @@ public sealed class AzureDevOpsService : IAzureDevOpsService, IDisposable { private readonly AzureDevOpsOptions _options; private readonly ILogger _logger; - private readonly VssConnection _connection; - private readonly WorkItemTrackingHttpClient _witClient; - private readonly GitHttpClient _gitClient; - private readonly BuildHttpClient _buildClient; - private readonly WikiHttpClient _wikiClient; + private readonly IAzureDevOpsOrganizationContextAccessor _organizationContextAccessor; + private readonly IReadOnlyDictionary _organizationContexts; + private readonly AzureDevOpsOrganizationContext _defaultOrganizationContext; private bool _disposed; + private WorkItemTrackingHttpClient WitClient => GetOrganizationContext().WitClient; + private GitHttpClient GitClient => GetOrganizationContext().GitClient; + private BuildHttpClient BuildClient => GetOrganizationContext().BuildClient; + private WikiHttpClient WikiClient => GetOrganizationContext().WikiClient; + private string? DefaultProject => GetOrganizationContext().DefaultProject; + private string OrganizationUrl => GetOrganizationContext().OrganizationUrl; + private static readonly string[] DefaultFields = [ "System.Id", @@ -63,19 +68,153 @@ public sealed class AzureDevOpsService : IAzureDevOpsService, IDisposable "System.Parent" ]; - public AzureDevOpsService(IOptions options, ILogger logger) + public AzureDevOpsService( + IOptions options, + ILogger logger, + IAzureDevOpsOrganizationContextAccessor organizationContextAccessor) { _options = options.Value; _logger = logger; + _organizationContextAccessor = organizationContextAccessor; + + var contexts = _options.GetConfiguredOrganizations() + .Select(CreateOrganizationContext) + .ToList(); + + if (contexts.Count == 0) + { + throw new InvalidOperationException("At least one Azure DevOps organization must be configured."); + } + + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var context in contexts) + { + RegisterOrganizationKey(lookup, context.Name, context); + RegisterOrganizationKey(lookup, context.OrganizationUrl, context); + + var organizationNameFromUrl = GetOrganizationNameFromUrl(context.OrganizationUrl); + if (!string.IsNullOrWhiteSpace(organizationNameFromUrl)) + { + RegisterOrganizationKey(lookup, organizationNameFromUrl, context); + } + } + + _organizationContexts = lookup; + _defaultOrganizationContext = ResolveDefaultOrganization(contexts); + + _logger.LogInformation( + "Azure DevOps service initialized for {OrganizationCount} organization(s); default organization: {DefaultOrganization}", + contexts.Count, + _defaultOrganizationContext.Name); + } + + private AzureDevOpsOrganizationContext GetOrganizationContext() + { + var organization = _organizationContextAccessor.CurrentOrganization; + if (string.IsNullOrWhiteSpace(organization)) + { + return _defaultOrganizationContext; + } + + var key = NormalizeOrganizationKey(organization); + if (_organizationContexts.TryGetValue(key, out var context)) + { + return context; + } + + throw new InvalidOperationException( + $"Azure DevOps organization '{organization}' is not configured. Configure it under AzureDevOps:Organizations or use the default organization."); + } + + private AzureDevOpsOrganizationContext ResolveDefaultOrganization(IReadOnlyList contexts) + { + if (string.IsNullOrWhiteSpace(_options.DefaultOrganization)) + { + return contexts[0]; + } + + var key = NormalizeOrganizationKey(_options.DefaultOrganization); + if (_organizationContexts.TryGetValue(key, out var context)) + { + return context; + } + + throw new InvalidOperationException( + $"AzureDevOps:DefaultOrganization '{_options.DefaultOrganization}' does not match any configured organization."); + } + + private static AzureDevOpsOrganizationContext CreateOrganizationContext(AzureDevOpsOrganizationOptions organization) + { + var organizationUrl = organization.OrganizationUrl?.Trim() + ?? throw new InvalidOperationException("Azure DevOps organization URL is required."); + var personalAccessToken = organization.PersonalAccessToken + ?? throw new InvalidOperationException($"Azure DevOps organization '{organizationUrl}' requires a PAT."); + var organizationName = string.IsNullOrWhiteSpace(organization.Name) + ? GetOrganizationNameFromUrl(organizationUrl) ?? organizationUrl + : organization.Name.Trim(); + + var credentials = new VssBasicCredential(string.Empty, personalAccessToken); + var connection = new VssConnection(new Uri(organizationUrl), credentials); + + return new AzureDevOpsOrganizationContext + { + Name = organizationName, + OrganizationUrl = organizationUrl.TrimEnd('/'), + DefaultProject = string.IsNullOrWhiteSpace(organization.DefaultProject) + ? null + : organization.DefaultProject.Trim(), + Connection = connection, + WitClient = connection.GetClient(), + GitClient = connection.GetClient(), + BuildClient = connection.GetClient(), + WikiClient = connection.GetClient() + }; + } + + private static void RegisterOrganizationKey( + IDictionary lookup, + string? key, + AzureDevOpsOrganizationContext context) + { + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + var normalizedKey = NormalizeOrganizationKey(key); + if (lookup.TryGetValue(normalizedKey, out var existing) && !ReferenceEquals(existing, context)) + { + throw new InvalidOperationException($"Duplicate Azure DevOps organization key '{key}'."); + } + + lookup[normalizedKey] = context; + } + + private static string NormalizeOrganizationKey(string organization) => + organization.Trim().TrimEnd('/').ToLowerInvariant(); + + private static string? GetOrganizationNameFromUrl(string? organizationUrl) + { + if (string.IsNullOrWhiteSpace(organizationUrl) || + !Uri.TryCreate(organizationUrl, UriKind.Absolute, out var uri)) + { + return null; + } + + if (uri.Host.Equals("dev.azure.com", StringComparison.OrdinalIgnoreCase)) + { + return uri.Segments + .Select(segment => segment.Trim('/')) + .FirstOrDefault(segment => !string.IsNullOrWhiteSpace(segment)); + } - var credentials = new VssBasicCredential(string.Empty, _options.PersonalAccessToken); - _connection = new VssConnection(new Uri(_options.OrganizationUrl), credentials); - _witClient = _connection.GetClient(); - _gitClient = _connection.GetClient(); - _buildClient = _connection.GetClient(); - _wikiClient = _connection.GetClient(); + const string visualStudioSuffix = ".visualstudio.com"; + if (uri.Host.EndsWith(visualStudioSuffix, StringComparison.OrdinalIgnoreCase)) + { + return uri.Host[..^visualStudioSuffix.Length]; + } - _logger.LogInformation("Azure DevOps service initialized for organization: {OrganizationUrl}", _options.OrganizationUrl); + return uri.Host; } public async Task GetWorkItemAsync(int workItemId, string? project = null, CancellationToken cancellationToken = default) @@ -84,8 +223,8 @@ public AzureDevOpsService(IOptions options, ILogger> GetWorkItemsAsync(IEnumerable { _logger.LogDebug("Getting {Count} work items", ids.Count); - var workItems = await _witClient.GetWorkItemsAsync( - project: project ?? _options.DefaultProject, + var workItems = await WitClient.GetWorkItemsAsync( + project: project ?? DefaultProject, ids: ids, expand: WorkItemExpand.All, cancellationToken: cancellationToken); @@ -133,9 +272,9 @@ public async Task> QueryWorkItemsAsync(string wiqlQue _logger.LogDebug("Executing WIQL query"); var wiql = new Wiql { Query = wiqlQuery }; - var queryResult = await _witClient.QueryByWiqlAsync( + var queryResult = await WitClient.QueryByWiqlAsync( wiql: wiql, - project: project ?? _options.DefaultProject, + project: project ?? DefaultProject, top: top, cancellationToken: cancellationToken); @@ -168,7 +307,7 @@ public async Task> QueryWorkItemsAsync(string wiqlQue public async Task> GetChildWorkItemsAsync(int parentWorkItemId, string? project = null, CancellationToken cancellationToken = default) { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; var wiqlQuery = $@" SELECT [System.Id] FROM WorkItemLinks @@ -181,7 +320,7 @@ FROM WorkItemLinks _logger.LogDebug("Getting child work items for parent {ParentWorkItemId}", parentWorkItemId); var wiql = new Wiql { Query = wiqlQuery }; - var queryResult = await _witClient.QueryByWiqlAsync( + var queryResult = await WitClient.QueryByWiqlAsync( wiql: wiql, project: projectName, cancellationToken: cancellationToken); @@ -251,10 +390,10 @@ public async Task LinkWorkItemsAsync( }); } - var result = await _witClient.UpdateWorkItemAsync( + var result = await WitClient.UpdateWorkItemAsync( document: patchDocument, id: sourceWorkItemId, - project: project ?? _options.DefaultProject, + project: project ?? DefaultProject, cancellationToken: cancellationToken); return MapToDto(result, includeAllFields: true); @@ -287,9 +426,9 @@ public async Task> QueryWorkItemsSummaryAsyn var wiql = new Wiql { Query = wiqlQuery }; // First, get all matching IDs to determine total count - var queryResult = await _witClient.QueryByWiqlAsync( + var queryResult = await WitClient.QueryByWiqlAsync( wiql: wiql, - project: project ?? _options.DefaultProject, + project: project ?? DefaultProject, cancellationToken: cancellationToken); if (queryResult.WorkItems == null || !queryResult.WorkItems.Any()) @@ -324,8 +463,8 @@ public async Task> QueryWorkItemsSummaryAsyn } // Fetch only summary fields for the page items - var workItems = await _witClient.GetWorkItemsAsync( - project: project ?? _options.DefaultProject, + var workItems = await WitClient.GetWorkItemsAsync( + project: project ?? DefaultProject, ids: pageIds, fields: SummaryFields, cancellationToken: cancellationToken); @@ -537,7 +676,7 @@ private static (string? repositoryId, string? artifactId)? ExtractGitArtifactInf } private string BuildWorkItemUrl(int workItemId) => - $"{_options.OrganizationUrl.TrimEnd('/')}/_apis/wit/workItems/{workItemId}"; + $"{OrganizationUrl.TrimEnd('/')}/_apis/wit/workItems/{workItemId}"; public async Task AddWorkItemCommentAsync( int workItemId, @@ -549,10 +688,10 @@ public async Task AddWorkItemCommentAsync( { _logger.LogDebug("Adding comment to work item {WorkItemId}", workItemId); - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; var request = new CommentCreate { Text = comment }; - var createdComment = await _witClient.AddCommentAsync( + var createdComment = await WitClient.AddCommentAsync( request: request, project: projectName, workItemId: workItemId, @@ -591,8 +730,8 @@ public async Task GetWorkItemCommentsAsync( CommentExpandOptions? expand = includeRenderedText ? CommentExpandOptions.RenderedText : null; - var list = await _witClient.GetCommentsAsync( - project: project ?? _options.DefaultProject, + var list = await WitClient.GetCommentsAsync( + project: project ?? DefaultProject, workItemId: workItemId, top: top, continuationToken: continuationToken, @@ -645,8 +784,8 @@ public async Task> GetWorkItemAttachmentsAs { _logger.LogDebug("Getting attachments for work item {WorkItemId}", workItemId); - var workItem = await _witClient.GetWorkItemAsync( - project: project ?? _options.DefaultProject, + var workItem = await WitClient.GetWorkItemAsync( + project: project ?? DefaultProject, id: workItemId, expand: WorkItemExpand.Relations, cancellationToken: cancellationToken); @@ -688,8 +827,8 @@ public async Task> GetWorkItemAttachmentsAs { _logger.LogDebug("Downloading attachment {AttachmentId}", attachmentId); - using var stream = await _witClient.GetAttachmentContentAsync( - project: project ?? _options.DefaultProject, + using var stream = await WitClient.GetAttachmentContentAsync( + project: project ?? DefaultProject, id: attachmentId, cancellationToken: cancellationToken); @@ -964,7 +1103,7 @@ public async Task CreateWorkItemAsync( } } - var result = await _witClient.CreateWorkItemAsync( + var result = await WitClient.CreateWorkItemAsync( document: patchDocument, project: project, type: workItemType, @@ -1103,10 +1242,10 @@ public async Task UpdateWorkItemAsync( return currentWorkItem!; } - var result = await _witClient.UpdateWorkItemAsync( + var result = await WitClient.UpdateWorkItemAsync( document: patchDocument, id: workItemId, - project: project ?? _options.DefaultProject, + project: project ?? DefaultProject, cancellationToken: cancellationToken); return MapToDto(result, includeAllFields: true); @@ -1124,10 +1263,10 @@ public async Task> GetRepositoriesAsync(string? pro { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting repositories for project {Project}", projectName); - var repositories = await _gitClient.GetRepositoriesAsync( + var repositories = await GitClient.GetRepositoriesAsync( project: projectName, cancellationToken: cancellationToken); @@ -1144,10 +1283,10 @@ public async Task> GetRepositoriesAsync(string? pro { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting repository {Repository} for project {Project}", repositoryNameOrId, projectName); - var repository = await _gitClient.GetRepositoryAsync( + var repository = await GitClient.GetRepositoryAsync( project: projectName, repositoryId: repositoryNameOrId, cancellationToken: cancellationToken); @@ -1165,10 +1304,10 @@ public async Task> GetBranchesAsync(string repositoryNa { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting branches for repository {Repository}", repositoryNameOrId); - var branches = await _gitClient.GetBranchesAsync( + var branches = await GitClient.GetBranchesAsync( project: projectName, repositoryId: repositoryNameOrId, cancellationToken: cancellationToken); @@ -1192,7 +1331,7 @@ public async Task> GetItemsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting items at path {Path} in repository {Repository}", path, repositoryNameOrId); var versionDescriptor = string.IsNullOrEmpty(branchName) @@ -1210,7 +1349,7 @@ public async Task> GetItemsAsync( _ => VersionControlRecursionType.OneLevel }; - var items = await _gitClient.GetItemsAsync( + var items = await GitClient.GetItemsAsync( project: projectName, repositoryId: repositoryNameOrId, scopePath: path, @@ -1236,7 +1375,7 @@ public async Task> GetItemsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting file content at path {Path} in repository {Repository}", filePath, repositoryNameOrId); var versionDescriptor = string.IsNullOrEmpty(branchName) @@ -1248,7 +1387,7 @@ public async Task> GetItemsAsync( }; // First get the item metadata - var item = await _gitClient.GetItemAsync( + var item = await GitClient.GetItemAsync( project: projectName, repositoryId: repositoryNameOrId, path: filePath, @@ -1275,7 +1414,7 @@ public async Task> GetItemsAsync( } // Get the content stream - using var contentStream = await _gitClient.GetItemContentAsync( + using var contentStream = await GitClient.GetItemContentAsync( project: projectName, repositoryId: repositoryNameOrId, path: filePath, @@ -1368,7 +1507,7 @@ public async Task> GetPullRequestsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting pull requests for repository {Repository}", repositoryNameOrId); var searchCriteria = new GitPullRequestSearchCriteria @@ -1380,7 +1519,7 @@ public async Task> GetPullRequestsAsync( TargetRefName = targetRefName }; - var pullRequests = await _gitClient.GetPullRequestsAsync( + var pullRequests = await GitClient.GetPullRequestsAsync( project: projectName, repositoryId: repositoryNameOrId, searchCriteria: searchCriteria, @@ -1405,10 +1544,10 @@ public async Task> GetPullRequestsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting pull request {PullRequestId} for repository {Repository}", pullRequestId, repositoryNameOrId); - var pullRequest = await _gitClient.GetPullRequestAsync( + var pullRequest = await GitClient.GetPullRequestAsync( project: projectName, repositoryId: repositoryNameOrId, pullRequestId: pullRequestId, @@ -1430,10 +1569,10 @@ public async Task> GetPullRequestsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting pull request {PullRequestId} at project level", pullRequestId); - var pullRequest = await _gitClient.GetPullRequestByIdAsync( + var pullRequest = await GitClient.GetPullRequestByIdAsync( pullRequestId: pullRequestId, project: projectName, cancellationToken: cancellationToken); @@ -1455,10 +1594,10 @@ public async Task> GetPullRequestThreadsAsyn { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting threads for pull request {PullRequestId}", pullRequestId); - var threads = await _gitClient.GetThreadsAsync( + var threads = await GitClient.GetThreadsAsync( project: projectName, repositoryId: repositoryNameOrId, pullRequestId: pullRequestId, @@ -1523,9 +1662,9 @@ public async Task CreatePullRequestThreadAsync( }; } - var created = await _gitClient.CreateThreadAsync( + var created = await GitClient.CreateThreadAsync( commentThread: thread, - project: project ?? _options.DefaultProject, + project: project ?? DefaultProject, repositoryId: repositoryNameOrId, pullRequestId: pullRequestId, userState: null, @@ -1555,7 +1694,7 @@ public async Task AddPullRequestThreadCommentAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug( "Adding comment to thread {ThreadId} on pull request {PullRequestId}", threadId, @@ -1568,7 +1707,7 @@ public async Task AddPullRequestThreadCommentAsync( CommentType = CommentType.Text }; - var created = await _gitClient.CreateCommentAsync( + var created = await GitClient.CreateCommentAsync( comment: comment, repositoryId: repositoryNameOrId, pullRequestId: pullRequestId, @@ -1608,7 +1747,7 @@ public async Task UpdatePullRequestThreadStatusAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; var threadStatus = ParseCommentThreadStatus(status) ?? throw new ArgumentException($"Unsupported pull request thread status '{status}'", nameof(status)); @@ -1623,7 +1762,7 @@ public async Task UpdatePullRequestThreadStatusAsync( Status = threadStatus }; - var updated = await _gitClient.UpdateThreadAsync( + var updated = await GitClient.UpdateThreadAsync( commentThread: thread, project: projectName, repositoryId: repositoryNameOrId, @@ -1656,7 +1795,7 @@ public async Task> SearchPullRequestsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Searching pull requests with text '{SearchText}' in repository {Repository}", searchText, repositoryNameOrId); // Get pull requests with status filter @@ -1665,7 +1804,7 @@ public async Task> SearchPullRequestsAsync( Status = ParsePullRequestStatus(status) }; - var pullRequests = await _gitClient.GetPullRequestsAsync( + var pullRequests = await GitClient.GetPullRequestsAsync( project: projectName, repositoryId: repositoryNameOrId, searchCriteria: searchCriteria, @@ -1705,7 +1844,7 @@ public async Task CreatePullRequestAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Creating pull request in repository {Repository}", repositoryNameOrId); var gitPullRequest = new GitPullRequest @@ -1733,7 +1872,7 @@ public async Task CreatePullRequestAsync( if (workItemIds != null) { var workItemRefs = workItemIds - .Select(id => new ResourceRef { Id = id.ToString(), Url = $"{_options.OrganizationUrl}/_apis/wit/workItems/{id}" }) + .Select(id => new ResourceRef { Id = id.ToString(), Url = $"{OrganizationUrl}/_apis/wit/workItems/{id}" }) .ToList(); if (workItemRefs.Count > 0) @@ -1742,7 +1881,7 @@ public async Task CreatePullRequestAsync( } } - var result = await _gitClient.CreatePullRequestAsync( + var result = await GitClient.CreatePullRequestAsync( gitPullRequestToCreate: gitPullRequest, repositoryId: repositoryNameOrId, project: projectName, @@ -1770,7 +1909,7 @@ public async Task UpdatePullRequestAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug( "Updating pull request {PullRequestId} in repository {Repository}", pullRequestId, @@ -1804,7 +1943,7 @@ public async Task UpdatePullRequestAsync( pullRequestUpdate.IsDraft = isDraft.Value; } - var result = await _gitClient.UpdatePullRequestAsync( + var result = await GitClient.UpdatePullRequestAsync( gitPullRequestToUpdate: pullRequestUpdate, project: projectName, repositoryId: repositoryNameOrId, @@ -1940,10 +2079,10 @@ public async Task> GetPipelinesAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting pipelines for project {Project}", projectName); - var definitions = await _buildClient.GetDefinitionsAsync( + var definitions = await BuildClient.GetDefinitionsAsync( project: projectName, name: name, path: folder, @@ -1966,10 +2105,10 @@ public async Task> GetPipelinesAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting pipeline {PipelineId}", pipelineId); - var definition = await _buildClient.GetDefinitionAsync( + var definition = await BuildClient.GetDefinitionAsync( project: projectName, definitionId: pipelineId, cancellationToken: cancellationToken); @@ -1995,10 +2134,10 @@ public async Task> GetBuildsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting builds for project {Project}", projectName); - var builds = await _buildClient.GetBuildsAsync( + var builds = await BuildClient.GetBuildsAsync( project: projectName, definitions: definitions?.ToList(), branchName: branchName, @@ -2024,10 +2163,10 @@ public async Task> GetBuildsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting build {BuildId}", buildId); - var build = await _buildClient.GetBuildAsync( + var build = await BuildClient.GetBuildAsync( project: projectName, buildId: buildId, cancellationToken: cancellationToken); @@ -2048,10 +2187,10 @@ public async Task> GetBuildLogsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting logs for build {BuildId}", buildId); - var logs = await _buildClient.GetBuildLogsAsync( + var logs = await BuildClient.GetBuildLogsAsync( project: projectName, buildId: buildId, cancellationToken: cancellationToken); @@ -2073,10 +2212,10 @@ public async Task> GetBuildLogsAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting log content for build {BuildId}, log {LogId}", buildId, logId); - var logLines = await _buildClient.GetBuildLogLinesAsync( + var logLines = await BuildClient.GetBuildLogLinesAsync( project: projectName, buildId: buildId, logId: logId, @@ -2098,10 +2237,10 @@ public async Task> GetBuildTimelineAsync( { try { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogDebug("Getting timeline for build {BuildId}", buildId); - var timeline = await _buildClient.GetBuildTimelineAsync( + var timeline = await BuildClient.GetBuildTimelineAsync( project: projectName, buildId: buildId, cancellationToken: cancellationToken); @@ -2238,13 +2377,13 @@ private static BuildTimelineRecordDto MapToBuildTimelineRecordDto(TimelineRecord public async Task> GetWikisAsync(string? project = null, CancellationToken cancellationToken = default) { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogInformation("Getting wikis for project: {Project}", projectName ?? "(all)"); try { - var wikis = await _wikiClient.GetAllWikisAsync(project: projectName, cancellationToken: cancellationToken); + var wikis = await WikiClient.GetAllWikisAsync(project: projectName, cancellationToken: cancellationToken); _logger.LogInformation("Found {Count} wikis", wikis.Count); @@ -2259,13 +2398,13 @@ public async Task> GetWikisAsync(string? project = null, public async Task GetWikiAsync(string wikiIdentifier, string? project = null, CancellationToken cancellationToken = default) { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogInformation("Getting wiki: {WikiIdentifier} in project: {Project}", wikiIdentifier, projectName ?? "(default)"); try { - var wiki = await _wikiClient.GetWikiAsync(project: projectName, wikiIdentifier: wikiIdentifier, cancellationToken: cancellationToken); + var wiki = await WikiClient.GetWikiAsync(project: projectName, wikiIdentifier: wikiIdentifier, cancellationToken: cancellationToken); return MapToWikiDto(wiki); } catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || @@ -2284,7 +2423,7 @@ public async Task> GetWikisAsync(string? project = null, string? project = null, CancellationToken cancellationToken = default) { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogInformation("Getting wiki page: {Path} from wiki: {WikiIdentifier}", path, wikiIdentifier); @@ -2294,7 +2433,7 @@ public async Task> GetWikisAsync(string? project = null, ? new GitVersionDescriptor { Version = version, VersionType = GitVersionType.Branch } : null; - var page = await _wikiClient.GetPageAsync( + var page = await WikiClient.GetPageAsync( project: projectName, wikiIdentifier: wikiIdentifier, path: path, @@ -2320,7 +2459,7 @@ public async Task> GetWikisAsync(string? project = null, string? project = null, CancellationToken cancellationToken = default) { - var projectName = project ?? _options.DefaultProject; + var projectName = project ?? DefaultProject; _logger.LogInformation("Getting wiki page tree: {Path} from wiki: {WikiIdentifier} with recursion: {Recursion}", path, wikiIdentifier, recursionLevel); @@ -2330,7 +2469,7 @@ public async Task> GetWikisAsync(string? project = null, ? VersionControlRecursionType.Full : VersionControlRecursionType.OneLevel; - var page = await _wikiClient.GetPageAsync( + var page = await WikiClient.GetPageAsync( project: projectName, wikiIdentifier: wikiIdentifier, path: path, @@ -2406,11 +2545,39 @@ public void Dispose() { if (_disposed) return; - _witClient.Dispose(); - _gitClient.Dispose(); - _buildClient.Dispose(); - _wikiClient.Dispose(); - _connection.Dispose(); + foreach (var context in _organizationContexts.Values.Distinct()) + { + context.Dispose(); + } + _disposed = true; } + + private sealed class AzureDevOpsOrganizationContext : IDisposable + { + public required string Name { get; init; } + + public required string OrganizationUrl { get; init; } + + public string? DefaultProject { get; init; } + + public required VssConnection Connection { get; init; } + + public required WorkItemTrackingHttpClient WitClient { get; init; } + + public required GitHttpClient GitClient { get; init; } + + public required BuildHttpClient BuildClient { get; init; } + + public required WikiHttpClient WikiClient { get; init; } + + public void Dispose() + { + WitClient.Dispose(); + GitClient.Dispose(); + BuildClient.Dispose(); + WikiClient.Dispose(); + Connection.Dispose(); + } + } } diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsOrganizationContextAccessor.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsOrganizationContextAccessor.cs new file mode 100644 index 0000000..e9aebd3 --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsOrganizationContextAccessor.cs @@ -0,0 +1,17 @@ +namespace Viamus.Azure.Devops.Mcp.Server.Services; + +/// +/// Stores the organization selected for the current MCP tool invocation. +/// +public interface IAzureDevOpsOrganizationContextAccessor +{ + /// + /// The selected organization alias or URL for the current async flow. + /// + string? CurrentOrganization { get; } + + /// + /// Selects an organization for the current async flow. + /// + IDisposable Use(string? organization); +} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/GitTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/GitTools.cs index c995f93..7bf4f4a 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/GitTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/GitTools.cs @@ -12,6 +12,7 @@ namespace Viamus.Azure.Devops.Mcp.Server.Tools; public sealed class GitTools { private readonly IAzureDevOpsService _azureDevOpsService; + private readonly IAzureDevOpsOrganizationContextAccessor _organizationContextAccessor; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -19,16 +20,26 @@ public sealed class GitTools }; public GitTools(IAzureDevOpsService azureDevOpsService) + : this(azureDevOpsService, new AzureDevOpsOrganizationContextAccessor()) + { + } + + public GitTools( + IAzureDevOpsService azureDevOpsService, + IAzureDevOpsOrganizationContextAccessor organizationContextAccessor) { _azureDevOpsService = azureDevOpsService; + _organizationContextAccessor = organizationContextAccessor; } [McpServerTool(Name = "get_repositories")] [Description("Gets all Git repositories in an Azure DevOps project. Returns repository details including name, default branch, URLs, and size.")] public async Task GetRepositories( [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var repositories = await _azureDevOpsService.GetRepositoriesAsync(project, cancellationToken); return JsonSerializer.Serialize(new { count = repositories.Count, repositories }, JsonOptions); } @@ -38,8 +49,10 @@ public async Task GetRepositories( public async Task GetRepository( [Description("The repository name or ID")] string repositoryNameOrId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -60,8 +73,10 @@ public async Task GetRepository( public async Task GetBranches( [Description("The repository name or ID")] string repositoryNameOrId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -79,8 +94,10 @@ public async Task GetRepositoryItems( [Description("The branch name (optional, uses default branch if not specified)")] string? branchName = null, [Description("The project name (optional if default project is configured)")] string? project = null, [Description("Recursion level: 'None' (only specified item), 'OneLevel' (immediate children), 'Full' (all descendants). Default is 'OneLevel'.")] string recursionLevel = "OneLevel", + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -106,8 +123,10 @@ public async Task GetFileContent( [Description("The path to the file (e.g., '/src/Program.cs')")] string filePath, [Description("The branch name (optional, uses default branch if not specified)")] string? branchName = null, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -141,8 +160,10 @@ public async Task SearchRepositoryFiles( [Description("The starting path to search from (default is root '/')")] string path = "/", [Description("The branch name (optional, uses default branch if not specified)")] string? branchName = null, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PipelineTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PipelineTools.cs index 1f955d2..b080e14 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PipelineTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PipelineTools.cs @@ -12,6 +12,7 @@ namespace Viamus.Azure.Devops.Mcp.Server.Tools; public sealed class PipelineTools { private readonly IAzureDevOpsService _azureDevOpsService; + private readonly IAzureDevOpsOrganizationContextAccessor _organizationContextAccessor; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -19,8 +20,16 @@ public sealed class PipelineTools }; public PipelineTools(IAzureDevOpsService azureDevOpsService) + : this(azureDevOpsService, new AzureDevOpsOrganizationContextAccessor()) + { + } + + public PipelineTools( + IAzureDevOpsService azureDevOpsService, + IAzureDevOpsOrganizationContextAccessor organizationContextAccessor) { _azureDevOpsService = azureDevOpsService; + _organizationContextAccessor = organizationContextAccessor; } [McpServerTool(Name = "get_pipelines")] @@ -30,8 +39,10 @@ public async Task GetPipelines( [Description("Optional filter by pipeline name (supports wildcards like 'MyPipeline*')")] string? name = null, [Description("Optional filter by folder path (e.g., '\\folder\\subfolder')")] string? folder = null, [Description("Maximum number of results to return (default: 100)")] int top = 100, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var pipelines = await _azureDevOpsService.GetPipelinesAsync(project, name, folder, top, cancellationToken); return JsonSerializer.Serialize(new @@ -46,8 +57,10 @@ public async Task GetPipelines( public async Task GetPipeline( [Description("The pipeline (build definition) ID")] int pipelineId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (pipelineId <= 0) { return JsonSerializer.Serialize(new { error = "Pipeline ID must be a positive integer" }, JsonOptions); @@ -72,8 +85,10 @@ public async Task GetPipelineRuns( [Description("Filter by status: 'all', 'inProgress', 'completed', 'cancelling', 'postponed', 'notStarted', 'none'")] string? statusFilter = null, [Description("Filter by result: 'succeeded', 'partiallySucceeded', 'failed', 'canceled', 'none'")] string? resultFilter = null, [Description("Maximum number of results to return (default: 20)")] int top = 20, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (pipelineId <= 0) { return JsonSerializer.Serialize(new { error = "Pipeline ID must be a positive integer" }, JsonOptions); @@ -95,8 +110,10 @@ public async Task GetPipelineRuns( public async Task GetBuild( [Description("The build ID")] int buildId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (buildId <= 0) { return JsonSerializer.Serialize(new { error = "Build ID must be a positive integer" }, JsonOptions); @@ -122,8 +139,10 @@ public async Task GetBuilds( [Description("Filter by result: 'succeeded', 'partiallySucceeded', 'failed', 'canceled', 'none'")] string? resultFilter = null, [Description("Filter by who requested the build")] string? requestedFor = null, [Description("Maximum number of results to return (default: 50)")] int top = 50, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); IEnumerable? definitionIds = null; if (!string.IsNullOrWhiteSpace(definitions)) { @@ -164,8 +183,10 @@ public async Task GetBuilds( public async Task GetBuildLogs( [Description("The build ID")] int buildId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (buildId <= 0) { return JsonSerializer.Serialize(new { error = "Build ID must be a positive integer" }, JsonOptions); @@ -187,8 +208,10 @@ public async Task GetBuildLogContent( [Description("The build ID")] int buildId, [Description("The log ID (from get_build_logs)")] int logId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (buildId <= 0) { return JsonSerializer.Serialize(new { error = "Build ID must be a positive integer" }, JsonOptions); @@ -219,8 +242,10 @@ public async Task GetBuildLogContent( public async Task GetBuildTimeline( [Description("The build ID")] int buildId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (buildId <= 0) { return JsonSerializer.Serialize(new { error = "Build ID must be a positive integer" }, JsonOptions); @@ -257,8 +282,10 @@ public async Task QueryBuilds( [Description("Filter by result: 'succeeded', 'partiallySucceeded', 'failed', 'canceled', 'none'")] string? resultFilter = null, [Description("Filter by who requested the build")] string? requestedFor = null, [Description("Maximum number of results to return (default: 50)")] int top = 50, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); IEnumerable? definitionIds = null; if (!string.IsNullOrWhiteSpace(definitions)) { diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs index 19f353a..59d93ad 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs @@ -12,6 +12,7 @@ namespace Viamus.Azure.Devops.Mcp.Server.Tools; public sealed class PullRequestTools { private readonly IAzureDevOpsService _azureDevOpsService; + private readonly IAzureDevOpsOrganizationContextAccessor _organizationContextAccessor; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -19,8 +20,16 @@ public sealed class PullRequestTools }; public PullRequestTools(IAzureDevOpsService azureDevOpsService) + : this(azureDevOpsService, new AzureDevOpsOrganizationContextAccessor()) + { + } + + public PullRequestTools( + IAzureDevOpsService azureDevOpsService, + IAzureDevOpsOrganizationContextAccessor organizationContextAccessor) { _azureDevOpsService = azureDevOpsService; + _organizationContextAccessor = organizationContextAccessor; } [McpServerTool(Name = "get_pull_requests")] @@ -35,8 +44,10 @@ public async Task GetPullRequests( [Description("Filter by target branch (e.g., 'refs/heads/main')")] string? targetRefName = null, [Description("Maximum number of results to return (default: 50)")] int top = 50, [Description("Number of results to skip for pagination (default: 0)")] int skip = 0, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -60,8 +71,10 @@ public async Task GetPullRequest( [Description("The repository name or ID")] string repositoryNameOrId, [Description("The pull request ID")] int pullRequestId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -88,8 +101,10 @@ public async Task GetPullRequest( public async Task GetPullRequestById( [Description("The pull request ID")] int pullRequestId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (pullRequestId <= 0) { return JsonSerializer.Serialize(new { error = "Pull request ID must be a positive integer" }, JsonOptions); @@ -112,8 +127,10 @@ public async Task GetPullRequestThreads( [Description("The repository name or ID")] string repositoryNameOrId, [Description("The pull request ID")] int pullRequestId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -146,8 +163,10 @@ public async Task CreatePullRequestThread( [Description("Optional line number for an inline thread on the right/new file")] int? lineNumber = null, [Description("Optional ending line number for an inline thread range on the right/new file")] int? endLineNumber = null, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -199,8 +218,10 @@ public async Task AddPullRequestThreadComment( [Description("The comment text (Markdown supported)")] string content, [Description("Optional parent comment ID when replying to a specific comment in the thread")] int? parentCommentId = null, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -241,8 +262,10 @@ public async Task UpdatePullRequestThreadStatus( [Description("The thread ID (from get_pull_request_threads)")] int threadId, [Description("Target status: Active, Fixed, Closed, WontFix, ByDesign, Pending, or aliases like close/resolve")] string status, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -287,8 +310,10 @@ public async Task SearchPullRequests( [Description("The project name (optional if default project is configured)")] string? project = null, [Description("Filter by status: 'active', 'completed', 'abandoned', or 'all' (default: all)")] string? status = null, [Description("Maximum number of results to return (default: 50)")] int top = 50, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -340,8 +365,10 @@ public async Task CreatePullRequest( [Description("The project name (optional if default project is configured)")] string? project = null, [Description("Semicolon-separated reviewer GUIDs (e.g., 'guid1;guid2')")] string? reviewerIds = null, [Description("Semicolon-separated work item IDs to link (e.g., '123;456')")] string? workItemIds = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -396,8 +423,10 @@ public async Task UpdatePullRequest( [Description("New status: Active, Abandoned, or Completed. Aliases include open, reopen, abandon, complete, and merge.")] string? status = null, [Description("New draft flag. Use true to mark as draft, false to mark ready for review.")] bool? isDraft = null, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); @@ -467,8 +496,10 @@ public async Task QueryPullRequests( [Description("Filter by target branch (e.g., 'refs/heads/main')")] string? targetRefName = null, [Description("Maximum number of results to return (default: 50)")] int top = 50, [Description("Number of results to skip for pagination (default: 0)")] int skip = 0, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(repositoryNameOrId)) { return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs index f494cb5..891f877 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs @@ -12,6 +12,7 @@ namespace Viamus.Azure.Devops.Mcp.Server.Tools; public sealed class WikiTools { private readonly IAzureDevOpsService _azureDevOpsService; + private readonly IAzureDevOpsOrganizationContextAccessor _organizationContextAccessor; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -19,16 +20,26 @@ public sealed class WikiTools }; public WikiTools(IAzureDevOpsService azureDevOpsService) + : this(azureDevOpsService, new AzureDevOpsOrganizationContextAccessor()) + { + } + + public WikiTools( + IAzureDevOpsService azureDevOpsService, + IAzureDevOpsOrganizationContextAccessor organizationContextAccessor) { _azureDevOpsService = azureDevOpsService; + _organizationContextAccessor = organizationContextAccessor; } [McpServerTool(Name = "get_wikis")] [Description("Gets all wikis in an Azure DevOps project. Returns wiki details including name, type (projectWiki or codeWiki), repository, and versions.")] public async Task GetWikis( [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var wikis = await _azureDevOpsService.GetWikisAsync(project, cancellationToken); return JsonSerializer.Serialize(new { count = wikis.Count, wikis }, JsonOptions); } @@ -38,8 +49,10 @@ public async Task GetWikis( public async Task GetWiki( [Description("The wiki name or ID")] string wikiIdentifier, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(wikiIdentifier)) { return JsonSerializer.Serialize(new { error = "Wiki name or ID is required" }, JsonOptions); @@ -63,8 +76,10 @@ public async Task GetWikiPage( [Description("Whether to include the page Markdown content (default true)")] bool includeContent = true, [Description("Optional version/branch of the wiki")] string? version = null, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(wikiIdentifier)) { return JsonSerializer.Serialize(new { error = "Wiki name or ID is required" }, JsonOptions); @@ -96,8 +111,10 @@ public async Task GetWikiPageTree( [Description("The parent page path to browse (default is root '/')")] string path = "/", [Description("Recursion level: 'OneLevel' (immediate children) or 'Full' (all descendants). Default is 'OneLevel'.")] string recursionLevel = "OneLevel", [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(wikiIdentifier)) { return JsonSerializer.Serialize(new { error = "Wiki name or ID is required" }, JsonOptions); diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs index 4b96b7f..9d7a948 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs @@ -12,6 +12,7 @@ namespace Viamus.Azure.Devops.Mcp.Server.Tools; public sealed class WorkItemTools { private readonly IAzureDevOpsService _azureDevOpsService; + private readonly IAzureDevOpsOrganizationContextAccessor _organizationContextAccessor; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -19,8 +20,16 @@ public sealed class WorkItemTools }; public WorkItemTools(IAzureDevOpsService azureDevOpsService) + : this(azureDevOpsService, new AzureDevOpsOrganizationContextAccessor()) + { + } + + public WorkItemTools( + IAzureDevOpsService azureDevOpsService, + IAzureDevOpsOrganizationContextAccessor organizationContextAccessor) { _azureDevOpsService = azureDevOpsService; + _organizationContextAccessor = organizationContextAccessor; } [McpServerTool(Name = "get_work_item")] @@ -28,8 +37,10 @@ public WorkItemTools(IAzureDevOpsService azureDevOpsService) public async Task GetWorkItem( [Description("The ID of the work item to retrieve")] int workItemId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var workItem = await _azureDevOpsService.GetWorkItemAsync(workItemId, project, cancellationToken); if (workItem is null) @@ -45,8 +56,10 @@ public async Task GetWorkItem( public async Task GetWorkItems( [Description("Comma-separated list of work item IDs to retrieve (e.g., '123,456,789')")] string workItemIds, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var ids = ParseWorkItemIds(workItemIds); if (ids.Count == 0) @@ -65,8 +78,10 @@ public async Task QueryWorkItems( [Description("The project name (optional)")] string? project = null, [Description("Page number, starting from 1 (default: 1)")] int page = 1, [Description("Number of items per page (default: 20, max: 20)")] int pageSize = 20, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var result = await _azureDevOpsService.QueryWorkItemsSummaryAsync(wiqlQuery, project, page, pageSize, cancellationToken); return JsonSerializer.Serialize(new { @@ -88,8 +103,10 @@ public async Task GetWorkItemsByState( [Description("Optional work item type filter (e.g., 'Bug', 'Task', 'User Story')")] string? workItemType = null, [Description("Page number, starting from 1 (default: 1)")] int page = 1, [Description("Number of items per page (default: 20, max: 20)")] int pageSize = 20, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var typeFilter = string.IsNullOrWhiteSpace(workItemType) ? string.Empty : $" AND [System.WorkItemType] = '{EscapeWiqlString(workItemType)}'"; @@ -124,8 +141,10 @@ public async Task GetWorkItemsAssignedTo( [Description("Filter by state (optional, e.g., 'Active')")] string? state = null, [Description("Page number, starting from 1 (default: 1)")] int page = 1, [Description("Number of items per page (default: 20, max: 20)")] int pageSize = 20, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var stateFilter = string.IsNullOrWhiteSpace(state) ? string.Empty : $" AND [System.State] = '{EscapeWiqlString(state)}'"; @@ -156,8 +175,10 @@ FROM WorkItems public async Task GetChildWorkItems( [Description("The ID of the parent work item")] int parentWorkItemId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var workItems = await _azureDevOpsService.GetChildWorkItemsAsync(parentWorkItemId, project, cancellationToken); return JsonSerializer.Serialize(new { parentWorkItemId, count = workItems.Count, children = workItems }, JsonOptions); } @@ -170,8 +191,10 @@ public async Task LinkWorkItems( [Description("Relation from the source work item's perspective: parent, child, predecessor, successor, related, or a System.LinkTypes.* reference name")] string relationType, [Description("The project name (optional if default project is configured)")] string? project = null, [Description("Optional comment to store on the relation")] string? comment = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (sourceWorkItemId <= 0) { return JsonSerializer.Serialize(new { error = "sourceWorkItemId must be a positive integer" }, JsonOptions); @@ -232,8 +255,10 @@ public async Task GetRecentWorkItems( [Description("Number of days to look back (default: 7, max: 30)")] int daysBack = 7, [Description("Page number, starting from 1 (default: 1)")] int page = 1, [Description("Number of items per page (default: 20, max: 20)")] int pageSize = 20, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); daysBack = Math.Clamp(daysBack, 1, 30); var sinceDate = DateTime.UtcNow.AddDays(-daysBack).ToString("yyyy-MM-dd"); @@ -268,8 +293,10 @@ public async Task SearchWorkItems( [Description("Optional work item type filter (e.g., 'Bug', 'Task', 'User Story')")] string? workItemType = null, [Description("Page number, starting from 1 (default: 1)")] int page = 1, [Description("Number of items per page (default: 20, max: 20)")] int pageSize = 20, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); var typeFilter = string.IsNullOrWhiteSpace(workItemType) ? string.Empty : $" AND [System.WorkItemType] = '{EscapeWiqlString(workItemType)}'"; @@ -301,8 +328,10 @@ FROM WorkItems public async Task GetWorkItemAttachments( [Description("The ID of the work item")] int workItemId, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (workItemId <= 0) { return JsonSerializer.Serialize(new { error = "Work item ID must be a positive integer" }, JsonOptions); @@ -319,14 +348,16 @@ public async Task GetWorkItemAttachments( } [McpServerTool(Name = "get_work_item_attachment_content")] - [Description("Downloads the content of a work item attachment by its GUID (use get_work_item_attachments to discover GUIDs) and returns it inline. Text files come back as UTF-8; binary files as base64-encoded bytes. Refuses files larger than maxBytes (default 10 MB) — for those, use the URL from get_work_item_attachments and download out-of-band.")] + [Description("Downloads the content of a work item attachment by its GUID (use get_work_item_attachments to discover GUIDs) and returns it inline. Text files come back as UTF-8; binary files as base64-encoded bytes. Refuses files larger than maxBytes (default 10 MB) — for those, use the URL from get_work_item_attachments and download out-of-band.")] public async Task GetWorkItemAttachmentContent( [Description("The attachment GUID (e.g., '2ee06d3b-4ea4-4390-80de-474a4e1e4355')")] string attachmentId, [Description("Optional original filename (echoed back in the response)")] string? fileName = null, [Description("The project name (optional if default project is configured)")] string? project = null, [Description("Maximum bytes to download (default 10485760 = 10 MB). Larger attachments are rejected.")] int maxBytes = 10 * 1024 * 1024, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (!Guid.TryParse(attachmentId, out var guid)) { return JsonSerializer.Serialize(new { error = "attachmentId must be a valid GUID" }, JsonOptions); @@ -354,8 +385,10 @@ public async Task AddWorkItemComment( [Description("The ID of the work item to comment on")] int workItemId, [Description("The comment text to add")] string comment, [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(comment)) { return JsonSerializer.Serialize(new { error = "Comment text cannot be empty" }, JsonOptions); @@ -380,8 +413,10 @@ public async Task GetWorkItemComments( [Description("Whether to include deleted comments (default: false)")] bool includeDeleted = false, [Description("Sort order: 'asc' (oldest first) or 'desc' (newest first). Defaults to server order.")] string? order = null, [Description("If true, includes the rendered HTML of each comment in addition to its Markdown text (default: false)")] bool includeRenderedText = false, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (workItemId <= 0) { return JsonSerializer.Serialize(new { error = "workItemId must be a positive integer" }, JsonOptions); @@ -429,8 +464,10 @@ public async Task CreateWorkItem( [Description("The ID of the parent work item to link to")] int? parentId = null, [Description("Semicolon-separated tags (e.g., 'tag1; tag2; tag3')")] string? tags = null, [Description("JSON string of additional fields as key-value pairs (e.g., '{\"Custom.Field\": \"value\"}')")] string? additionalFields = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (string.IsNullOrWhiteSpace(project)) { return JsonSerializer.Serialize(new { error = "Project name is required" }, JsonOptions); @@ -488,8 +525,10 @@ public async Task UpdateWorkItem( [Description("New priority (1-4, where 1 is highest)")] int? priority = null, [Description("New semicolon-separated tags (e.g., 'tag1; tag2; tag3')")] string? tags = null, [Description("JSON string of additional fields as key-value pairs (e.g., '{\"Custom.Field\": \"value\"}')")] string? additionalFields = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, CancellationToken cancellationToken = default) { + using var organizationScope = _organizationContextAccessor.Use(organization); if (workItemId <= 0) { return JsonSerializer.Serialize(new { error = "Work item ID must be a positive integer" }, JsonOptions); diff --git a/src/Viamus.Azure.Devops.Mcp.Server/appsettings.json b/src/Viamus.Azure.Devops.Mcp.Server/appsettings.json index 071d17d..7c33a68 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/appsettings.json +++ b/src/Viamus.Azure.Devops.Mcp.Server/appsettings.json @@ -9,7 +9,9 @@ "AzureDevOps": { "OrganizationUrl": "", "PersonalAccessToken": "", - "DefaultProject": "" + "DefaultProject": "", + "DefaultOrganization": "", + "Organizations": [] }, "ServerSecurity": { "ApiKey": "", diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Configuration/AzureDevOpsOptionsTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Configuration/AzureDevOpsOptionsTests.cs new file mode 100644 index 0000000..5450d57 --- /dev/null +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Configuration/AzureDevOpsOptionsTests.cs @@ -0,0 +1,76 @@ +using Viamus.Azure.Devops.Mcp.Server.Configuration; + +namespace Viamus.Azure.Devops.Mcp.Server.Tests.Configuration; + +public sealed class AzureDevOpsOptionsTests +{ + [Fact] + public void GetConfiguredOrganizations_WithLegacySettings_ShouldReturnDefaultOrganization() + { + var options = new AzureDevOpsOptions + { + OrganizationUrl = "https://dev.azure.com/contoso", + PersonalAccessToken = "pat", + DefaultProject = "MainProject" + }; + + var organizations = options.GetConfiguredOrganizations(); + + Assert.Single(organizations); + Assert.Equal("contoso", organizations[0].Name); + Assert.Equal("https://dev.azure.com/contoso", organizations[0].OrganizationUrl); + Assert.Equal("pat", organizations[0].PersonalAccessToken); + Assert.Equal("MainProject", organizations[0].DefaultProject); + } + + [Fact] + public void Validate_WithOrganizationsOnly_ShouldSucceed() + { + var options = new AzureDevOpsOptions + { + DefaultOrganization = "secondary", + Organizations = + [ + new AzureDevOpsOrganizationOptions + { + Name = "primary", + OrganizationUrl = "https://dev.azure.com/primary", + PersonalAccessToken = "primary-pat", + DefaultProject = "PrimaryProject" + }, + new AzureDevOpsOrganizationOptions + { + Name = "secondary", + OrganizationUrl = "https://dev.azure.com/secondary", + PersonalAccessToken = "secondary-pat", + DefaultProject = "SecondaryProject" + } + ] + }; + + var errors = options.Validate(); + + Assert.Empty(errors); + Assert.Equal(2, options.GetConfiguredOrganizations().Count); + } + + [Fact] + public void Validate_WithMissingOrganizationPat_ShouldReturnError() + { + var options = new AzureDevOpsOptions + { + Organizations = + [ + new AzureDevOpsOrganizationOptions + { + Name = "primary", + OrganizationUrl = "https://dev.azure.com/primary" + } + ] + }; + + var errors = options.Validate(); + + Assert.Contains(errors, error => error.Contains("requires PersonalAccessToken", StringComparison.Ordinal)); + } +}