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));
+ }
+}