Skip to content

Commit fdeefe0

Browse files
Novaesaaudzei
andauthored
feat: add MCP server "domains" (#421)
Add domains ## GitHub issue number #386 ## **Associated Risks** _Replace_ by possible risks this pull request can bring you might have thought of ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or **N/A** -- N/A: tracking domain enabling will be a follow up - [ ] 📄 Documentation added, updated, or **N/A** -- N/A: we aligned PM handling it - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Unit test added Local run - Inspector **Post-Update Status:** - **Wiki + Work Items** Tools loaded reduced from **all tools** to **18 tools** <img width="956" height="904" alt="image" src="https://github.com/user-attachments/assets/3ae7c5ec-2749-4638-8338-284a57b77f84" /> **Repositories + Test Plans** Tools loaded reduced from **all tools** to **23 tools** <img width="1000" height="940" alt="image" src="https://github.com/user-attachments/assets/56e8b811-9df3-4dd8-ad15-7cc504793647" /> VS Code <img width="1617" height="1204" alt="image" src="https://github.com/user-attachments/assets/b1cfda6b-fe37-45b4-a8b9-d54b4b4d32b5" /> **Note on UI**: Domain selection is not supported in the UI, and VS Code lacks multi-select for pickString. Domains are required explicitly; documentation are available to assist.. Workarounds exist but are not prioritized. This UI limitation should be addressed with Groups and Tags coming. <img width="471" height="392" alt="image" src="https://github.com/user-attachments/assets/809b38f2-56c2-44e2-aca1-6e24fec9bcb5" /> ### Customer Experience Given We are considering pushing a draft spec generator, the page for customers would look like: ## Context [SEP-993](modelcontextprotocol/modelcontextprotocol#993) is moving to **Groups and Tags** We have been discussing with interested parties and authors, Groups is quite loosely coupled, which can be categorized with: * Semantic Groups * Functional Groups It makes sense for Groups and Tags proposal be as generic as possible. With domains we introduce a semantic group which in terms of implementation is just syntax sugar for groups, but it carries more governance on MCP servers around those. Some open questions when Groups go to prod: * Some other edge case behaviors: e.g. behavior for someone defining domains and groups * Standard way to expose and catalog those domains (give viz at Microsoft for them) **Important** * Not all MCP servers necessarily require to have domains. For instance, if your groups are organized around functional roles rather than strict semantic boundaries, it may be reasonable not to enforce domain governance. * For those servers that do require domain structuring, this capability is supported through the Groups and Tags SEP. Domains are syntax sugar for groups, which appear to be the de-facto implementation approach at the protocol level. ### More info on protocol alignment: Tool Filtering with groups and Tags More info: [SEP-1300: Tool Filtering with Groups and Tags · Issue #1300 · modelcontextprotocol/modelcontextprotocol](modelcontextprotocol/modelcontextprotocol#1300) > Client-side filtering of tools: Agent fetches all the tools with no filtering, organizes them by groups and tags within the host application, then provides the appropriate tools to the LLM when needed via whatever means, e.g., semantic search. > Server-side filtering of tools: Agent presents a list of groups to the LLM. When the LLM decides it needs a tool but doesn't have an appropriate one in its context, it may request the list of tools in a particular group or set of groups. --------- Co-authored-by: aaudzei <[email protected]>
1 parent 9251adf commit fdeefe0

17 files changed

+450
-33
lines changed

intTest/domains/mcp.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"servers": {
3+
"ado": {
4+
"type": "stdio",
5+
"command": "mcp-server-azuredevops",
6+
"args": [
7+
"${input:ado_org}",
8+
"-d",
9+
"core",
10+
"work",
11+
"workitems"
12+
// ... any other domain to enable, you can also use 'all' (which is already the default)
13+
]
14+
}
15+
},
16+
"inputs": [
17+
{
18+
"id": "ado_org",
19+
"type": "promptString",
20+
"description": "Azure DevOps organization name (e.g. 'contoso')"
21+
}
22+
]
23+
}

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure-devops/mcp",
3-
"version": "1.3.1",
3+
"version": "2.0.0",
44
"description": "MCP server for interacting with Azure DevOps",
55
"license": "MIT",
66
"author": "Microsoft Corporation",

src/index.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,27 @@ import { configurePrompts } from "./prompts.js";
1414
import { configureAllTools } from "./tools.js";
1515
import { UserAgentComposer } from "./useragent.js";
1616
import { packageVersion } from "./version.js";
17+
import { DomainsManager } from "./shared/domains.js";
1718

1819
// Parse command line arguments using yargs
1920
const argv = yargs(hideBin(process.argv))
2021
.scriptName("mcp-server-azuredevops")
2122
.usage("Usage: $0 <organization> [options]")
2223
.version(packageVersion)
23-
.command("$0 <organization>", "Azure DevOps MCP Server", (yargs) => {
24+
.command("$0 <organization> [options]", "Azure DevOps MCP Server", (yargs) => {
2425
yargs.positional("organization", {
2526
describe: "Azure DevOps organization name",
2627
type: "string",
28+
demandOption: true,
2729
});
2830
})
31+
.option("domains", {
32+
alias: "d",
33+
describe: "Domain(s) to enable: 'all' for everything, or specific domains like 'repositories builds work'. Defaults to 'all'.",
34+
type: "string",
35+
array: true,
36+
default: "all",
37+
})
2938
.option("tenant", {
3039
alias: "t",
3140
describe: "Azure tenant ID (optional, required for multi-tenant scenarios)",
@@ -34,10 +43,14 @@ const argv = yargs(hideBin(process.argv))
3443
.help()
3544
.parseSync();
3645

37-
export const orgName = argv.organization as string;
3846
const tenantId = argv.tenant;
47+
48+
export const orgName = argv.organization as string;
3949
const orgUrl = "https://dev.azure.com/" + orgName;
4050

51+
const domainsManager = new DomainsManager(argv.domains);
52+
export const enabledDomains = domainsManager.getEnabledDomains();
53+
4154
async function getAzureDevOpsToken(): Promise<AccessToken> {
4255
if (process.env.ADO_MCP_AZURE_TOKEN_CREDENTIALS) {
4356
process.env.AZURE_TOKEN_CREDENTIALS = process.env.ADO_MCP_AZURE_TOKEN_CREDENTIALS;
@@ -84,7 +97,7 @@ async function main() {
8497

8598
configurePrompts(server);
8699

87-
configureAllTools(server, getAzureDevOpsToken, getAzureDevOpsClient(userAgentComposer), () => userAgentComposer.userAgent);
100+
configureAllTools(server, getAzureDevOpsToken, getAzureDevOpsClient(userAgentComposer), () => userAgentComposer.userAgent, enabledDomains);
88101

89102
const transport = new StdioServerTransport();
90103
await server.connect(transport);

src/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import { z } from "zod";
66
import { CORE_TOOLS } from "./tools/core.js";
7-
import { WORKITEM_TOOLS } from "./tools/workitems.js";
7+
import { WORKITEM_TOOLS } from "./tools/work-items.js";
88

99
function configurePrompts(server: McpServer) {
1010
server.prompt("Projects", "Lists all projects in the Azure DevOps organization.", {}, () => ({

src/shared/domains.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
/**
5+
* Available Azure DevOps MCP domains
6+
*/
7+
export enum Domain {
8+
ADVANCED_SECURITY = "advanced-security",
9+
BUILDS = "builds",
10+
CORE = "core",
11+
RELEASES = "releases",
12+
REPOSITORIES = "repositories",
13+
SEARCH = "search",
14+
TEST_PLANS = "test-plans",
15+
WIKI = "wiki",
16+
WORK = "work",
17+
WORK_ITEMS = "work-items",
18+
}
19+
20+
export const ALL_DOMAINS = "all";
21+
22+
/**
23+
* Manages domain parsing and validation for Azure DevOps MCP server tools
24+
*/
25+
export class DomainsManager {
26+
private static readonly AVAILABLE_DOMAINS = Object.values(Domain);
27+
28+
private readonly enabledDomains: Set<string>;
29+
30+
constructor(domainsInput?: string | string[]) {
31+
this.enabledDomains = new Set();
32+
const normalizedInput = DomainsManager.parseDomainsInput(domainsInput);
33+
this.parseDomains(normalizedInput);
34+
}
35+
36+
/**
37+
* Parse and validate domains from input
38+
* @param domainsInput - Either "all", single domain name, array of domain names, or undefined (defaults to "all")
39+
*/
40+
private parseDomains(domainsInput?: string | string[]): void {
41+
if (!domainsInput) {
42+
this.enableAllDomains();
43+
return;
44+
}
45+
46+
if (Array.isArray(domainsInput)) {
47+
this.handleArrayInput(domainsInput);
48+
return;
49+
}
50+
51+
this.handleStringInput(domainsInput);
52+
}
53+
54+
private handleArrayInput(domainsInput: string[]): void {
55+
if (domainsInput.length === 0 || domainsInput.includes(ALL_DOMAINS)) {
56+
this.enableAllDomains();
57+
return;
58+
}
59+
60+
if (domainsInput.length === 1 && domainsInput[0] === ALL_DOMAINS) {
61+
this.enableAllDomains();
62+
return;
63+
}
64+
65+
const domains = domainsInput.map((d) => d.trim().toLowerCase());
66+
this.validateAndAddDomains(domains);
67+
}
68+
69+
private handleStringInput(domainsInput: string): void {
70+
if (domainsInput === ALL_DOMAINS) {
71+
this.enableAllDomains();
72+
return;
73+
}
74+
75+
const domains = [domainsInput.trim().toLowerCase()];
76+
this.validateAndAddDomains(domains);
77+
}
78+
79+
private validateAndAddDomains(domains: string[]): void {
80+
const availableDomainsAsStringArray = Object.values(Domain) as string[];
81+
domains.forEach((domain) => {
82+
if (availableDomainsAsStringArray.includes(domain)) {
83+
this.enabledDomains.add(domain);
84+
} else if (domain === ALL_DOMAINS) {
85+
this.enableAllDomains();
86+
} else {
87+
console.error(`Error: Specified invalid domain '${domain}'. Please specify exactly as available domains: ${Object.values(Domain).join(", ")}`);
88+
}
89+
});
90+
91+
if (this.enabledDomains.size === 0) {
92+
this.enableAllDomains();
93+
}
94+
}
95+
96+
private enableAllDomains(): void {
97+
Object.values(Domain).forEach((domain) => this.enabledDomains.add(domain));
98+
}
99+
100+
/**
101+
* Check if a specific domain is enabled
102+
* @param domain - Domain name to check
103+
* @returns true if domain is enabled
104+
*/
105+
public isDomainEnabled(domain: string): boolean {
106+
return this.enabledDomains.has(domain);
107+
}
108+
109+
/**
110+
* Get all enabled domains
111+
* @returns Set of enabled domain names
112+
*/
113+
public getEnabledDomains(): Set<string> {
114+
return new Set(this.enabledDomains);
115+
}
116+
117+
/**
118+
* Get list of all available domains
119+
* @returns Array of available domain names
120+
*/
121+
public static getAvailableDomains(): string[] {
122+
return Object.values(Domain);
123+
}
124+
125+
/**
126+
* Parse domains input from string or array to a normalized array of strings
127+
* @param domainsInput - Domains input to parse
128+
* @returns Normalized array of domain strings
129+
*/
130+
public static parseDomainsInput(domainsInput?: string | string[]): string[] {
131+
if (!domainsInput) {
132+
return [];
133+
}
134+
135+
if (typeof domainsInput === "string") {
136+
return domainsInput.split(",").map((d) => d.trim().toLowerCase());
137+
}
138+
139+
return domainsInput.map((d) => d.trim().toLowerCase());
140+
}
141+
}

src/tools.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,35 @@ import { AccessToken } from "@azure/identity";
55
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { WebApi } from "azure-devops-node-api";
77

8-
import { configureAdvSecTools } from "./tools/advsec.js";
8+
import { Domain } from "./shared/domains.js";
9+
import { configureAdvSecTools } from "./tools/advanced-security.js";
910
import { configureBuildTools } from "./tools/builds.js";
1011
import { configureCoreTools } from "./tools/core.js";
1112
import { configureReleaseTools } from "./tools/releases.js";
12-
import { configureRepoTools } from "./tools/repos.js";
13+
import { configureRepoTools } from "./tools/repositories.js";
1314
import { configureSearchTools } from "./tools/search.js";
14-
import { configureTestPlanTools } from "./tools/testplans.js";
15+
import { configureTestPlanTools } from "./tools/test-plans.js";
1516
import { configureWikiTools } from "./tools/wiki.js";
1617
import { configureWorkTools } from "./tools/work.js";
17-
import { configureWorkItemTools } from "./tools/workitems.js";
18+
import { configureWorkItemTools } from "./tools/work-items.js";
1819

19-
function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
20-
configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider);
21-
configureWorkTools(server, tokenProvider, connectionProvider);
22-
configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
23-
configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
24-
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
25-
configureReleaseTools(server, tokenProvider, connectionProvider);
26-
configureWikiTools(server, tokenProvider, connectionProvider);
27-
configureTestPlanTools(server, tokenProvider, connectionProvider);
28-
configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider);
29-
configureAdvSecTools(server, tokenProvider, connectionProvider);
20+
function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string, enabledDomains: Set<string>) {
21+
const configureIfDomainEnabled = (domain: string, configureFn: () => void) => {
22+
if (enabledDomains.has(domain)) {
23+
configureFn();
24+
}
25+
};
26+
27+
configureIfDomainEnabled(Domain.CORE, () => configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider));
28+
configureIfDomainEnabled(Domain.WORK, () => configureWorkTools(server, tokenProvider, connectionProvider));
29+
configureIfDomainEnabled(Domain.BUILDS, () => configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider));
30+
configureIfDomainEnabled(Domain.REPOSITORIES, () => configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider));
31+
configureIfDomainEnabled(Domain.WORK_ITEMS, () => configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider));
32+
configureIfDomainEnabled(Domain.RELEASES, () => configureReleaseTools(server, tokenProvider, connectionProvider));
33+
configureIfDomainEnabled(Domain.WIKI, () => configureWikiTools(server, tokenProvider, connectionProvider));
34+
configureIfDomainEnabled(Domain.TEST_PLANS, () => configureTestPlanTools(server, tokenProvider, connectionProvider));
35+
configureIfDomainEnabled(Domain.SEARCH, () => configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider));
36+
configureIfDomainEnabled(Domain.ADVANCED_SECURITY, () => configureAdvSecTools(server, tokenProvider, connectionProvider));
3037
}
3138

3239
export { configureAllTools };
File renamed without changes.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)