Skip to content

feat: add MCP server "domains" #421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
10 changes: 8 additions & 2 deletions mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
{
"id": "ado_org",
"type": "promptString",
"description": "Azure DevOps organization name (e.g. 'contoso')"
"description": "Azure DevOps organization name (e.g. 'contoso')"
},
{
"id": "domains",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd avoid adding an input for new param. It creates friction for most of the users who are not planning to use it.
Instead mention it in the README, similar to --tenant option described in https://github.com/microsoft/azure-devops-mcp/blob/main/docs/TROUBLESHOOTING.md#solution

"type": "promptString",
"description": "Enter one or more Azure DevOps domains separated by commas. Available options: advanced-security, builds, core, releases, repositories, search, test-plans, wiki, work, work-items, all. (e.g. 'core,work-items,builds' or 'all')",
"default": "all"
}
],
"servers": {
"ado": {
"type": "stdio",
"command": "mcp-server-azuredevops",
"args": ["${input:ado_org}"]
"args": ["${input:ado_org}", "${input:domains}"]
}
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure-devops/mcp",
"version": "1.3.1",
"version": "2.0.0",
"description": "MCP server for interacting with Azure DevOps",
"license": "MIT",
"author": "Microsoft Corporation",
Expand Down
Empty file added src/domains.ts
Empty file.
21 changes: 17 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,27 @@ import { configurePrompts } from "./prompts.js";
import { configureAllTools } from "./tools.js";
import { UserAgentComposer } from "./useragent.js";
import { packageVersion } from "./version.js";
import { DomainsManager } from "./shared/domains.js";

// Parse command line arguments using yargs
const argv = yargs(hideBin(process.argv))
.scriptName("mcp-server-azuredevops")
.usage("Usage: $0 <organization> [options]")
.usage("Usage: $0 <organization> [domains...] [options]")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.usage("Usage: $0 <organization> [domains...] [options]")
.usage("Usage: $0 <organization> [options]")

domains has to be optional param, not positional

.version(packageVersion)
.command("$0 <organization>", "Azure DevOps MCP Server", (yargs) => {
.command("$0 <organization> [domains...] [options]", "Azure DevOps MCP Server", (yargs) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.command("$0 <organization> [domains...] [options]", "Azure DevOps MCP Server", (yargs) => {
.command("$0 <organization> [options]", "Azure DevOps MCP Server", (yargs) => {

yargs.positional("organization", {
describe: "Azure DevOps organization name",
type: "string",
demandOption: true,
});
})
.option("domains", {
alias: "d",
describe: "Domain(s) to enable: 'all' for everything, or specific domains like 'repositories builds work'. Defaults to 'all'.",
type: "string",
array: true,
default: "all",
})
.option("tenant", {
alias: "t",
describe: "Azure tenant ID (optional, required for multi-tenant scenarios)",
Expand All @@ -34,10 +43,14 @@ const argv = yargs(hideBin(process.argv))
.help()
.parseSync();

export const orgName = argv.organization as string;
const tenantId = argv.tenant;

export const orgName = argv.organization as string;
const orgUrl = "https://dev.azure.com/" + orgName;

const domainsManager = new DomainsManager(argv.domains);
export const enabledDomains = domainsManager.getEnabledDomains();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't find any import for the const. Consider moving it to a local const inside getAzureDevOpsToken


async function getAzureDevOpsToken(): Promise<AccessToken> {
if (process.env.ADO_MCP_AZURE_TOKEN_CREDENTIALS) {
process.env.AZURE_TOKEN_CREDENTIALS = process.env.ADO_MCP_AZURE_TOKEN_CREDENTIALS;
Expand Down Expand Up @@ -84,7 +97,7 @@ async function main() {

configurePrompts(server);

configureAllTools(server, getAzureDevOpsToken, getAzureDevOpsClient(userAgentComposer), () => userAgentComposer.userAgent);
configureAllTools(server, getAzureDevOpsToken, getAzureDevOpsClient(userAgentComposer), () => userAgentComposer.userAgent, enabledDomains);

const transport = new StdioServerTransport();
await server.connect(transport);
Expand Down
2 changes: 1 addition & 1 deletion src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { CORE_TOOLS } from "./tools/core.js";
import { WORKITEM_TOOLS } from "./tools/workitems.js";
import { WORKITEM_TOOLS } from "./tools/work-items.js";

function configurePrompts(server: McpServer) {
server.prompt("Projects", "Lists all projects in the Azure DevOps organization.", {}, () => ({
Expand Down
139 changes: 139 additions & 0 deletions src/shared/domains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Available Azure DevOps MCP domains
*/
export enum Domain {
ADVANCED_SECURITY = "advanced-security",
BUILDS = "builds",
CORE = "core",
RELEASES = "releases",
REPOSITORIES = "repositories",
SEARCH = "search",
TEST_PLANS = "test-plans",
WIKI = "wiki",
WORK = "work",
WORK_ITEMS = "work-items",
}

/**
* Manages domain parsing and validation for Azure DevOps MCP server tools
*/
export class DomainsManager {
private static readonly AVAILABLE_DOMAINS = Object.values(Domain);

private readonly enabledDomains: Set<string>;

constructor(domainsInput?: string | string[]) {
this.enabledDomains = new Set();
const normalizedInput = DomainsManager.parseDomainsInput(domainsInput);
this.parseDomains(normalizedInput);
}

/**
* Parse and validate domains from input
* @param domainsInput - Either "all", single domain name, array of domain names, or undefined (defaults to "all")
*/
private parseDomains(domainsInput?: string | string[]): void {
if (!domainsInput) {
console.log("No domains specified, enabling all domains for backward compatibility");
this.enableAllDomains();
return;
}

if (Array.isArray(domainsInput)) {
this.handleArrayInput(domainsInput);
return;
}

this.handleStringInput(domainsInput);
}

private handleArrayInput(domainsInput: string[]): void {
if (domainsInput.length === 0) {
console.log("No valid domains specified, enabling all domains by default");
this.enableAllDomains();
return;
}

if (domainsInput.length === 1 && domainsInput[0] === "all") {
this.enableAllDomains();
return;
}

const domains = domainsInput.map((d) => d.trim().toLowerCase());
this.validateAndAddDomains(domains);
}

private handleStringInput(domainsInput: string): void {
if (domainsInput === "all") {
this.enableAllDomains();
return;
}

const domains = [domainsInput.trim().toLowerCase()];
this.validateAndAddDomains(domains);
}

private validateAndAddDomains(domains: string[]): void {
domains.forEach((domain) => {
if ((Object.values(Domain) as string[]).includes(domain)) {
this.enabledDomains.add(domain);
} else {
console.warn(`Warning: Unknown domain '${domain}'. Available domains: ${Object.values(Domain).join(", ")}`);
}
});

if (this.enabledDomains.size === 0) {
console.log("No valid domains specified, enabling all domains by default");
this.enableAllDomains();
}
}

private enableAllDomains(): void {
Object.values(Domain).forEach((domain) => this.enabledDomains.add(domain));
}

/**
* Check if a specific domain is enabled
* @param domain - Domain name to check
* @returns true if domain is enabled
*/
public isDomainEnabled(domain: string): boolean {
return this.enabledDomains.has(domain);
}

/**
* Get all enabled domains
* @returns Set of enabled domain names
*/
public getEnabledDomains(): Set<string> {
return new Set(this.enabledDomains);
}

/**
* Get list of all available domains
* @returns Array of available domain names
*/
public static getAvailableDomains(): string[] {
return Object.values(Domain);
}

/**
* Parse domains input from string or array to a normalized array of strings
* @param domainsInput - Domains input to parse
* @returns Normalized array of domain strings
*/
public static parseDomainsInput(domainsInput?: string | string[]): string[] {
if (!domainsInput) {
return [];
}

if (typeof domainsInput === "string") {
return domainsInput.split(",").map((d) => d.trim().toLowerCase());
}

return domainsInput.map((d) => d.trim().toLowerCase());
}
}
37 changes: 22 additions & 15 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,35 @@ import { AccessToken } from "@azure/identity";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebApi } from "azure-devops-node-api";

import { configureAdvSecTools } from "./tools/advsec.js";
import { Domain } from "./shared/domains.js";
import { configureAdvSecTools } from "./tools/advanced-security.js";
import { configureBuildTools } from "./tools/builds.js";
import { configureCoreTools } from "./tools/core.js";
import { configureReleaseTools } from "./tools/releases.js";
import { configureRepoTools } from "./tools/repos.js";
import { configureRepoTools } from "./tools/repositories.js";
import { configureSearchTools } from "./tools/search.js";
import { configureTestPlanTools } from "./tools/testplans.js";
import { configureTestPlanTools } from "./tools/test-plans.js";
import { configureWikiTools } from "./tools/wiki.js";
import { configureWorkTools } from "./tools/work.js";
import { configureWorkItemTools } from "./tools/workitems.js";
import { configureWorkItemTools } from "./tools/work-items.js";

function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider);
configureWorkTools(server, tokenProvider, connectionProvider);
configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
configureReleaseTools(server, tokenProvider, connectionProvider);
configureWikiTools(server, tokenProvider, connectionProvider);
configureTestPlanTools(server, tokenProvider, connectionProvider);
configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider);
configureAdvSecTools(server, tokenProvider, connectionProvider);
function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string, enabledDomains: Set<string>) {
const configureIfDomainEnabled = (domain: string, configureFn: () => void) => {
if (enabledDomains.has(domain)) {
configureFn();
}
};

configureIfDomainEnabled(Domain.CORE, () => configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider));
configureIfDomainEnabled(Domain.WORK, () => configureWorkTools(server, tokenProvider, connectionProvider));
configureIfDomainEnabled(Domain.BUILDS, () => configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider));
configureIfDomainEnabled(Domain.REPOSITORIES, () => configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider));
configureIfDomainEnabled(Domain.WORK_ITEMS, () => configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider));
configureIfDomainEnabled(Domain.RELEASES, () => configureReleaseTools(server, tokenProvider, connectionProvider));
configureIfDomainEnabled(Domain.WIKI, () => configureWikiTools(server, tokenProvider, connectionProvider));
configureIfDomainEnabled(Domain.TEST_PLANS, () => configureTestPlanTools(server, tokenProvider, connectionProvider));
configureIfDomainEnabled(Domain.SEARCH, () => configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider));
configureIfDomainEnabled(Domain.ADVANCED_SECURITY, () => configureAdvSecTools(server, tokenProvider, connectionProvider));
}

export { configureAllTools };
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const packageVersion = "1.3.1";
export const packageVersion = "2.0.0";
Loading
Loading