Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# --------------------------------------------
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
</details>

<details>
Expand All @@ -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
</details>

<details>
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions install-mcp-azure-devops.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -49,6 +52,8 @@ param(

[string]$DefaultProject = "",

[string]$DefaultOrganization = "",

[string]$ApiKey = "",

[bool]$RequireApiKey = $false
Expand Down Expand Up @@ -218,6 +223,8 @@ $appSettings = @{
OrganizationUrl = $OrganizationUrl
PersonalAccessToken = $PersonalAccessToken
DefaultProject = $DefaultProject
DefaultOrganization = $DefaultOrganization
Organizations = @()
}
ServerSecurity = @{
ApiKey = $ApiKey
Expand Down
128 changes: 125 additions & 3 deletions src/Viamus.Azure.Devops.Mcp.Server/Configuration/AzureDevOpsOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,140 @@ public sealed class AzureDevOpsOptions
{
public const string SectionName = "AzureDevOps";

/// <summary>
/// The default Azure DevOps organization URL (e.g., https://dev.azure.com/your-org).
/// Kept for backward compatibility with single-organization configuration.
/// </summary>
public string? OrganizationUrl { get; set; }

/// <summary>
/// Personal Access Token (PAT) for the default organization.
/// Kept for backward compatibility with single-organization configuration.
/// </summary>
public string? PersonalAccessToken { get; set; }

/// <summary>
/// Default project name (optional).
/// </summary>
public string? DefaultProject { get; set; }

/// <summary>
/// Default organization name or URL when multiple organizations are configured.
/// </summary>
public string? DefaultOrganization { get; set; }

/// <summary>
/// Additional Azure DevOps organizations, each with its own PAT.
/// </summary>
public IList<AzureDevOpsOrganizationOptions> Organizations { get; set; } = [];

/// <summary>
/// Gets all configured organizations, including the backward-compatible root settings.
/// </summary>
public IReadOnlyList<AzureDevOpsOrganizationOptions> GetConfiguredOrganizations()
{
var organizations = new List<AzureDevOpsOrganizationOptions>();

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

/// <summary>
/// Validates the Azure DevOps configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
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;
}
}

/// <summary>
/// Configuration options for one Azure DevOps organization.
/// </summary>
public sealed class AzureDevOpsOrganizationOptions
{
/// <summary>
/// Friendly organization alias used by tool calls.
/// </summary>
public string? Name { get; set; }

/// <summary>
/// The Azure DevOps organization URL (e.g., https://dev.azure.com/your-org).
/// </summary>
public required string OrganizationUrl { get; set; }
public string? OrganizationUrl { get; set; }

/// <summary>
/// Personal Access Token (PAT) for authentication.
/// </summary>
public required string PersonalAccessToken { get; set; }
public string? PersonalAccessToken { get; set; }

/// <summary>
/// Default project name (optional).
/// Default project name for this organization (optional).
/// </summary>
public string? DefaultProject { get; set; }
}
11 changes: 5 additions & 6 deletions src/Viamus.Azure.Devops.Mcp.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,15 @@

// Validate configuration on startup
var azureDevOpsConfig = builder.Configuration.GetSection(AzureDevOpsOptions.SectionName).Get<AzureDevOpsOptions>();
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<IAzureDevOpsOrganizationContextAccessor, AzureDevOpsOrganizationContextAccessor>();
builder.Services.AddSingleton<IAzureDevOpsService, AzureDevOpsService>();

// Configure MCP Server
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Viamus.Azure.Devops.Mcp.Server.Services;

/// <summary>
/// Async-local implementation for selecting an Azure DevOps organization per tool call.
/// </summary>
public sealed class AzureDevOpsOrganizationContextAccessor : IAzureDevOpsOrganizationContextAccessor
{
private readonly AsyncLocal<string?> _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;
}
}
}
Loading
Loading