Skip to content

Commit 148ee1b

Browse files
authored
Merge pull request #16 from viamus/codex/multi-org-pats
Support multiple Azure DevOps organization PATs
2 parents ea72509 + ccf90c2 commit 148ee1b

16 files changed

Lines changed: 750 additions & 110 deletions

.env.example

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@ AZURE_DEVOPS_PAT=your-personal-access-token
4444
#
4545
AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name
4646

47+
# --------------------------------------------
48+
# OPTIONAL: Multiple Organizations / PATs
49+
# --------------------------------------------
50+
# Use these when one MCP server must access multiple Azure DevOps
51+
# organizations with different PATs. The default organization is used when
52+
# a tool call does not include the optional "organization" argument.
53+
#
54+
# For appsettings.json, use AzureDevOps:Organizations as an array of objects.
55+
# For Docker environment variables, fill the indexed slots below or add more
56+
# AzureDevOps__Organizations__{n}__* entries in docker-compose.yml.
57+
#
58+
# Example values:
59+
# AZURE_DEVOPS_DEFAULT_ORGANIZATION=primary
60+
# AZURE_DEVOPS_ORGANIZATION_0_NAME=primary
61+
# AZURE_DEVOPS_ORGANIZATION_0_URL=https://dev.azure.com/your-primary-organization
62+
# AZURE_DEVOPS_ORGANIZATION_0_PAT=your-primary-personal-access-token
63+
# AZURE_DEVOPS_ORGANIZATION_0_DEFAULT_PROJECT=your-primary-project
64+
#
65+
AZURE_DEVOPS_DEFAULT_ORGANIZATION=
66+
67+
AZURE_DEVOPS_ORGANIZATION_0_NAME=
68+
AZURE_DEVOPS_ORGANIZATION_0_URL=
69+
AZURE_DEVOPS_ORGANIZATION_0_PAT=
70+
AZURE_DEVOPS_ORGANIZATION_0_DEFAULT_PROJECT=
71+
72+
AZURE_DEVOPS_ORGANIZATION_1_NAME=
73+
AZURE_DEVOPS_ORGANIZATION_1_URL=
74+
AZURE_DEVOPS_ORGANIZATION_1_PAT=
75+
AZURE_DEVOPS_ORGANIZATION_1_DEFAULT_PROJECT=
76+
4777
# --------------------------------------------
4878
# OPTIONAL: API Key Authentication
4979
# --------------------------------------------

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,30 @@ Edit `src/Viamus.Azure.Devops.Mcp.Server/appsettings.json` with your Azure DevOp
4343
}
4444
```
4545

46+
For multiple Azure DevOps organizations or PATs, keep one entry per organization and select it in tool calls with the optional `organization` argument:
47+
48+
```json
49+
{
50+
"AzureDevOps": {
51+
"DefaultOrganization": "primary",
52+
"Organizations": [
53+
{
54+
"Name": "primary",
55+
"OrganizationUrl": "https://dev.azure.com/primary-org",
56+
"PersonalAccessToken": "primary-pat",
57+
"DefaultProject": "primary-project"
58+
},
59+
{
60+
"Name": "secondary",
61+
"OrganizationUrl": "https://dev.azure.com/secondary-org",
62+
"PersonalAccessToken": "secondary-pat",
63+
"DefaultProject": "secondary-project"
64+
}
65+
]
66+
}
67+
}
68+
```
69+
4670
> **Need a PAT?** See [Creating a Personal Access Token](#creating-a-personal-access-token-pat) below.
4771
4872
### 2. Run the server
@@ -96,6 +120,8 @@ The script will automatically:
96120
- Configure your Azure DevOps credentials
97121
- Register the MCP server with Claude Code (HTTPS transport)
98122

123+
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`.
124+
99125
After installation, start the server:
100126
```powershell
101127
cd $env:USERPROFILE\mcp-azure-devops
@@ -198,6 +224,16 @@ This project implements an MCP server that exposes tools for querying and managi
198224

199225
5. Click **Create** and **copy the token immediately** (you won't see it again!)
200226

227+
### Multiple Organizations / PATs
228+
229+
The server supports one PAT per configured Azure DevOps organization. Existing single-organization settings still work:
230+
231+
- `AzureDevOps:OrganizationUrl`
232+
- `AzureDevOps:PersonalAccessToken`
233+
- `AzureDevOps:DefaultProject`
234+
235+
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}`.
236+
201237
---
202238

203239
## Running Options
@@ -212,6 +248,8 @@ Best for: Production use, quick setup without .NET installed
212248
# Edit .env with your Azure DevOps credentials
213249
```
214250

251+
For multiple organizations/PATs, fill `AZURE_DEVOPS_ORGANIZATION_0_*`, `AZURE_DEVOPS_ORGANIZATION_1_*`, and `AZURE_DEVOPS_DEFAULT_ORGANIZATION` in `.env`.
252+
215253
2. Start the server:
216254
```bash
217255
docker compose up -d
@@ -447,6 +485,7 @@ After configuring the MCP client, you can ask questions like:
447485
2. Check PAT hasn't expired in Azure DevOps
448486
3. Ensure PAT has required scopes (Work Items, Code, Build)
449487
4. Verify the organization URL is correct (no trailing slash)
488+
5. If using multiple organizations, verify the tool call's `organization` value matches a configured `Name`, URL, or Azure DevOps organization slug
450489
</details>
451490

452491
<details>
@@ -473,7 +512,7 @@ curl -H "X-API-Key: your-key" http://localhost:5000
473512

474513
1. Verify `AZURE_DEVOPS_DEFAULT_PROJECT` matches exact project name
475514
2. Or pass the project name explicitly in your queries
476-
3. Check PAT has access to the project
515+
3. Check the selected organization's PAT has access to the project
477516
</details>
478517

479518
<details>

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ services:
1212
- AzureDevOps__OrganizationUrl=${AZURE_DEVOPS_ORG_URL}
1313
- AzureDevOps__PersonalAccessToken=${AZURE_DEVOPS_PAT}
1414
- AzureDevOps__DefaultProject=${AZURE_DEVOPS_DEFAULT_PROJECT:-}
15+
- AzureDevOps__DefaultOrganization=${AZURE_DEVOPS_DEFAULT_ORGANIZATION:-}
16+
- AzureDevOps__Organizations__0__Name=${AZURE_DEVOPS_ORGANIZATION_0_NAME:-}
17+
- AzureDevOps__Organizations__0__OrganizationUrl=${AZURE_DEVOPS_ORGANIZATION_0_URL:-}
18+
- AzureDevOps__Organizations__0__PersonalAccessToken=${AZURE_DEVOPS_ORGANIZATION_0_PAT:-}
19+
- AzureDevOps__Organizations__0__DefaultProject=${AZURE_DEVOPS_ORGANIZATION_0_DEFAULT_PROJECT:-}
20+
- AzureDevOps__Organizations__1__Name=${AZURE_DEVOPS_ORGANIZATION_1_NAME:-}
21+
- AzureDevOps__Organizations__1__OrganizationUrl=${AZURE_DEVOPS_ORGANIZATION_1_URL:-}
22+
- AzureDevOps__Organizations__1__PersonalAccessToken=${AZURE_DEVOPS_ORGANIZATION_1_PAT:-}
23+
- AzureDevOps__Organizations__1__DefaultProject=${AZURE_DEVOPS_ORGANIZATION_1_DEFAULT_PROJECT:-}
1524
- ServerSecurity__ApiKey=${MCP_API_KEY:-}
1625
- ServerSecurity__RequireApiKey=${MCP_REQUIRE_API_KEY:-false}
1726
restart: unless-stopped

install-mcp-azure-devops.ps1

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
.PARAMETER DefaultProject
2626
Your default Azure DevOps project name (optional)
2727
28+
.PARAMETER DefaultOrganization
29+
Your default Azure DevOps organization alias when multiple organizations are configured (optional)
30+
2831
.PARAMETER ApiKey
2932
API key for MCP server authentication (optional). If provided, RequireApiKey is automatically enabled.
3033
@@ -49,6 +52,8 @@ param(
4952

5053
[string]$DefaultProject = "",
5154

55+
[string]$DefaultOrganization = "",
56+
5257
[string]$ApiKey = "",
5358

5459
[bool]$RequireApiKey = $false
@@ -218,6 +223,8 @@ $appSettings = @{
218223
OrganizationUrl = $OrganizationUrl
219224
PersonalAccessToken = $PersonalAccessToken
220225
DefaultProject = $DefaultProject
226+
DefaultOrganization = $DefaultOrganization
227+
Organizations = @()
221228
}
222229
ServerSecurity = @{
223230
ApiKey = $ApiKey

src/Viamus.Azure.Devops.Mcp.Server/Configuration/AzureDevOpsOptions.cs

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,140 @@ public sealed class AzureDevOpsOptions
77
{
88
public const string SectionName = "AzureDevOps";
99

10+
/// <summary>
11+
/// The default Azure DevOps organization URL (e.g., https://dev.azure.com/your-org).
12+
/// Kept for backward compatibility with single-organization configuration.
13+
/// </summary>
14+
public string? OrganizationUrl { get; set; }
15+
16+
/// <summary>
17+
/// Personal Access Token (PAT) for the default organization.
18+
/// Kept for backward compatibility with single-organization configuration.
19+
/// </summary>
20+
public string? PersonalAccessToken { get; set; }
21+
22+
/// <summary>
23+
/// Default project name (optional).
24+
/// </summary>
25+
public string? DefaultProject { get; set; }
26+
27+
/// <summary>
28+
/// Default organization name or URL when multiple organizations are configured.
29+
/// </summary>
30+
public string? DefaultOrganization { get; set; }
31+
32+
/// <summary>
33+
/// Additional Azure DevOps organizations, each with its own PAT.
34+
/// </summary>
35+
public IList<AzureDevOpsOrganizationOptions> Organizations { get; set; } = [];
36+
37+
/// <summary>
38+
/// Gets all configured organizations, including the backward-compatible root settings.
39+
/// </summary>
40+
public IReadOnlyList<AzureDevOpsOrganizationOptions> GetConfiguredOrganizations()
41+
{
42+
var organizations = new List<AzureDevOpsOrganizationOptions>();
43+
44+
if (!string.IsNullOrWhiteSpace(OrganizationUrl) || !string.IsNullOrWhiteSpace(PersonalAccessToken))
45+
{
46+
organizations.Add(new AzureDevOpsOrganizationOptions
47+
{
48+
Name = GetOrganizationNameFromUrl(OrganizationUrl) ?? "default",
49+
OrganizationUrl = OrganizationUrl,
50+
PersonalAccessToken = PersonalAccessToken,
51+
DefaultProject = DefaultProject
52+
});
53+
}
54+
55+
organizations.AddRange(Organizations.Where(o =>
56+
!string.IsNullOrWhiteSpace(o.Name) ||
57+
!string.IsNullOrWhiteSpace(o.OrganizationUrl) ||
58+
!string.IsNullOrWhiteSpace(o.PersonalAccessToken)));
59+
60+
return organizations;
61+
}
62+
63+
/// <summary>
64+
/// Validates the Azure DevOps configuration.
65+
/// </summary>
66+
public IReadOnlyList<string> Validate()
67+
{
68+
var errors = new List<string>();
69+
var organizations = GetConfiguredOrganizations();
70+
71+
if (organizations.Count == 0)
72+
{
73+
errors.Add("Configure AzureDevOps:OrganizationUrl and AzureDevOps:PersonalAccessToken, or at least one AzureDevOps:Organizations entry.");
74+
return errors;
75+
}
76+
77+
foreach (var organization in organizations)
78+
{
79+
var displayName = string.IsNullOrWhiteSpace(organization.Name)
80+
? organization.OrganizationUrl ?? "(unnamed organization)"
81+
: organization.Name;
82+
83+
if (string.IsNullOrWhiteSpace(organization.OrganizationUrl))
84+
{
85+
errors.Add($"Azure DevOps organization '{displayName}' requires OrganizationUrl.");
86+
}
87+
88+
if (string.IsNullOrWhiteSpace(organization.PersonalAccessToken))
89+
{
90+
errors.Add($"Azure DevOps organization '{displayName}' requires PersonalAccessToken.");
91+
}
92+
}
93+
94+
return errors;
95+
}
96+
97+
private static string? GetOrganizationNameFromUrl(string? organizationUrl)
98+
{
99+
if (string.IsNullOrWhiteSpace(organizationUrl) ||
100+
!Uri.TryCreate(organizationUrl, UriKind.Absolute, out var uri))
101+
{
102+
return null;
103+
}
104+
105+
if (uri.Host.Equals("dev.azure.com", StringComparison.OrdinalIgnoreCase))
106+
{
107+
return uri.Segments
108+
.Select(segment => segment.Trim('/'))
109+
.FirstOrDefault(segment => !string.IsNullOrWhiteSpace(segment));
110+
}
111+
112+
const string visualStudioSuffix = ".visualstudio.com";
113+
if (uri.Host.EndsWith(visualStudioSuffix, StringComparison.OrdinalIgnoreCase))
114+
{
115+
return uri.Host[..^visualStudioSuffix.Length];
116+
}
117+
118+
return uri.Host;
119+
}
120+
}
121+
122+
/// <summary>
123+
/// Configuration options for one Azure DevOps organization.
124+
/// </summary>
125+
public sealed class AzureDevOpsOrganizationOptions
126+
{
127+
/// <summary>
128+
/// Friendly organization alias used by tool calls.
129+
/// </summary>
130+
public string? Name { get; set; }
131+
10132
/// <summary>
11133
/// The Azure DevOps organization URL (e.g., https://dev.azure.com/your-org).
12134
/// </summary>
13-
public required string OrganizationUrl { get; set; }
135+
public string? OrganizationUrl { get; set; }
14136

15137
/// <summary>
16138
/// Personal Access Token (PAT) for authentication.
17139
/// </summary>
18-
public required string PersonalAccessToken { get; set; }
140+
public string? PersonalAccessToken { get; set; }
19141

20142
/// <summary>
21-
/// Default project name (optional).
143+
/// Default project name for this organization (optional).
22144
/// </summary>
23145
public string? DefaultProject { get; set; }
24146
}

src/Viamus.Azure.Devops.Mcp.Server/Program.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,15 @@
1515

1616
// Validate configuration on startup
1717
var azureDevOpsConfig = builder.Configuration.GetSection(AzureDevOpsOptions.SectionName).Get<AzureDevOpsOptions>();
18-
if (string.IsNullOrWhiteSpace(azureDevOpsConfig?.OrganizationUrl))
18+
var azureDevOpsValidationErrors = azureDevOpsConfig?.Validate() ?? ["AzureDevOps configuration is required."];
19+
if (azureDevOpsValidationErrors.Count > 0)
1920
{
20-
throw new InvalidOperationException("AzureDevOps:OrganizationUrl configuration is required.");
21-
}
22-
if (string.IsNullOrWhiteSpace(azureDevOpsConfig?.PersonalAccessToken))
23-
{
24-
throw new InvalidOperationException("AzureDevOps:PersonalAccessToken configuration is required.");
21+
throw new InvalidOperationException(
22+
"AzureDevOps configuration is invalid: " + string.Join(" ", azureDevOpsValidationErrors));
2523
}
2624

2725
// Register services
26+
builder.Services.AddSingleton<IAzureDevOpsOrganizationContextAccessor, AzureDevOpsOrganizationContextAccessor>();
2827
builder.Services.AddSingleton<IAzureDevOpsService, AzureDevOpsService>();
2928

3029
// Configure MCP Server
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
namespace Viamus.Azure.Devops.Mcp.Server.Services;
2+
3+
/// <summary>
4+
/// Async-local implementation for selecting an Azure DevOps organization per tool call.
5+
/// </summary>
6+
public sealed class AzureDevOpsOrganizationContextAccessor : IAzureDevOpsOrganizationContextAccessor
7+
{
8+
private readonly AsyncLocal<string?> _currentOrganization = new();
9+
10+
public string? CurrentOrganization => _currentOrganization.Value;
11+
12+
public IDisposable Use(string? organization)
13+
{
14+
var previous = _currentOrganization.Value;
15+
_currentOrganization.Value = string.IsNullOrWhiteSpace(organization)
16+
? null
17+
: organization.Trim();
18+
19+
return new Scope(this, previous);
20+
}
21+
22+
private sealed class Scope(AzureDevOpsOrganizationContextAccessor accessor, string? previous) : IDisposable
23+
{
24+
private bool _disposed;
25+
26+
public void Dispose()
27+
{
28+
if (_disposed)
29+
{
30+
return;
31+
}
32+
33+
accessor._currentOrganization.Value = previous;
34+
_disposed = true;
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)