Skip to content

Commit 22947c6

Browse files
authored
Merge branch 'main' into user/kyrader/add-support-access-token-via-env-var
2 parents 49c0abe + 52076fa commit 22947c6

22 files changed

+1470
-121
lines changed

.github/copilot-instructions.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ When adding new tool, always prioritize using an Azure DevOps Typescript client
1515
Only if the client or client method is not available, interact with the API directly.
1616
The tools are located in the `src/tools.ts` file.
1717

18-
## Adding new prompts
18+
## Using MCP Server for Azure DevOps
1919

20-
Ensure the instructions for the language model are clear and concise so that the language model can follow them reliably.
21-
The prompts are located in the `src/prompts.ts` file.
20+
When getting work items using MCP Server for Azure DevOps, always try to use batch tools for updates instead of many individual single updates. For updates, try and update up to 200 updates in a single batch. When getting work items, once you get the list of IDs, use the tool `get_work_items_batch_by_ids` to get the work item details.

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ For reference, see [this example of a well-formed issue](https://github.com/micr
2828

2929
## 👩‍💻 Writing code
3030

31-
We are accepting a limited number of pull requests during the public preview phase. If you notice something that should be changed or added, please create an issue first and provide details. Once reviewed, and if it makes sense to proceed, we will respond with a 👍.
31+
We’re currently accepting a limited number of pull requests, provided they follow the established process and remain simple in scope. If you notice something that should be changed or added, please **create an issue first** and provide details. Once reviewed, and if it makes sense to proceed, we will respond with a 👍.
3232

3333
Please include tests with your pull request. Pull requests will not be accepted until all relevant tests are updated and passing.
3434

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ Interact with these Azure DevOps services:
5555
- **work_list_team_iterations**: Retrieve a list of iterations for a specific team in a project.
5656
- **work_create_iterations**: Create new iterations in a specified Azure DevOps project.
5757
- **work_assign_iterations**: Assign existing iterations to a specific team in a project.
58+
- **work_get_team_capacity**: Get the team capacity of a specific team and iteration in a project.
59+
- **work_update_team_capacity**: Update the team capacity of a team member for a specific iteration in a project.
60+
- **work_get_iteration_capacities**: Get an iteration's capacity for all teams in iteration and project.
5861

5962
### 📅 Work Items
6063

package-lock.json

Lines changed: 87 additions & 87 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
@@ -38,7 +38,7 @@
3838
},
3939
"dependencies": {
4040
"@azure/identity": "^4.10.0",
41-
"@modelcontextprotocol/sdk": "1.20.0",
41+
"@modelcontextprotocol/sdk": "1.20.2",
4242
"azure-devops-extension-api": "^4.252.0",
4343
"azure-devops-extension-sdk": "^4.0.2",
4444
"azure-devops-node-api": "^15.1.0",

src/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
14
import { AzureCliCredential, ChainedTokenCredential, DefaultAzureCredential, TokenCredential } from "@azure/identity";
25
import { AccountInfo, AuthenticationResult, PublicClientApplication } from "@azure/msal-node";
36
import open from "open";
@@ -36,7 +39,7 @@ class OAuthAuthenticator {
3639
scopes,
3740
account: this.accountId,
3841
});
39-
} catch (error) {
42+
} catch {
4043
authResult = null;
4144
}
4245
}

src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8-
import * as azdev from "azure-devops-node-api";
8+
import { getBearerHandler, WebApi } from "azure-devops-node-api";
99
import yargs from "yargs";
1010
import { hideBin } from "yargs/helpers";
1111

@@ -63,11 +63,11 @@ const orgUrl = "https://dev.azure.com/" + orgName;
6363
const domainsManager = new DomainsManager(argv.domains);
6464
export const enabledDomains = domainsManager.getEnabledDomains();
6565

66-
function getAzureDevOpsClient(getAzureDevOpsToken: () => Promise<string>, userAgentComposer: UserAgentComposer): () => Promise<azdev.WebApi> {
66+
function getAzureDevOpsClient(getAzureDevOpsToken: () => Promise<string>, userAgentComposer: UserAgentComposer): () => Promise<WebApi> {
6767
return async () => {
6868
const accessToken = await getAzureDevOpsToken();
69-
const authHandler = azdev.getBearerHandler(accessToken);
70-
const connection = new azdev.WebApi(orgUrl, authHandler, undefined, {
69+
const authHandler = getBearerHandler(accessToken);
70+
const connection = new WebApi(orgUrl, authHandler, undefined, {
7171
productName: "AzureDevOps.MCP",
7272
productVersion: packageVersion,
7373
userAgent: userAgentComposer.userAgent,

src/org-tenants.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
1-
import * as fs from "fs/promises";
2-
import * as os from "os";
3-
import * as path from "path";
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { readFile, writeFile } from "fs/promises";
5+
import { homedir } from "os";
6+
import { join } from "path";
47

58
interface OrgTenantCacheEntry {
69
tenantId: string;
710
refreshedOn: number;
811
}
912

10-
interface OrgTenantCache {
11-
[orgName: string]: OrgTenantCacheEntry;
12-
}
13+
type OrgTenantCache = Record<string, OrgTenantCacheEntry>;
1314

14-
const CACHE_FILE = path.join(os.homedir(), ".ado_orgs.cache");
15+
const CACHE_FILE = join(homedir(), ".ado_orgs.cache");
1516
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds
1617

1718
async function loadCache(): Promise<OrgTenantCache> {
1819
try {
19-
const cacheData = await fs.readFile(CACHE_FILE, "utf-8");
20+
const cacheData = await readFile(CACHE_FILE, "utf-8");
2021
return JSON.parse(cacheData);
21-
} catch (error) {
22+
} catch {
2223
// Cache file doesn't exist or is invalid, return empty cache
2324
return {};
2425
}
2526
}
2627

2728
async function trySavingCache(cache: OrgTenantCache): Promise<void> {
2829
try {
29-
await fs.writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8");
30+
await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8");
3031
} catch (error) {
3132
console.error("Failed to save org tenants cache:", error);
3233
}

src/tools/work-items.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function getLinkTypeFromName(name: string) {
6464
function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
6565
server.tool(
6666
WORKITEM_TOOLS.list_backlogs,
67-
"Revieve a list of backlogs for a given project and team.",
67+
"Receive a list of backlogs for a given project and team.",
6868
{
6969
project: z.string().describe("The name or ID of the Azure DevOps project."),
7070
team: z.string().describe("The name or ID of the Azure DevOps team."),

src/tools/work.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const WORK_TOOLS = {
1010
list_team_iterations: "work_list_team_iterations",
1111
create_iterations: "work_create_iterations",
1212
assign_iterations: "work_assign_iterations",
13+
get_team_capacity: "work_get_team_capacity",
14+
update_team_capacity: "work_update_team_capacity",
15+
get_iteration_capacities: "work_get_iteration_capacities",
1316
};
1417

1518
function configureWorkTools(server: McpServer, _: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
@@ -150,6 +153,174 @@ function configureWorkTools(server: McpServer, _: () => Promise<string>, connect
150153
}
151154
}
152155
);
156+
157+
server.tool(
158+
WORK_TOOLS.get_team_capacity,
159+
"Get the team capacity of a specific team and iteration in a project.",
160+
{
161+
project: z.string().describe("The name or Id of the Azure DevOps project."),
162+
team: z.string().describe("The name or Id of the Azure DevOps team."),
163+
iterationId: z.string().describe("The Iteration Id to get capacity for."),
164+
},
165+
async ({ project, team, iterationId }) => {
166+
try {
167+
const connection = await connectionProvider();
168+
const workApi = await connection.getWorkApi();
169+
const teamContext = { project, team };
170+
171+
const rawResults = await workApi.getCapacitiesWithIdentityRefAndTotals(teamContext, iterationId);
172+
173+
if (!rawResults || rawResults.teamMembers?.length === 0) {
174+
return { content: [{ type: "text", text: "No team capacity assigned to the team" }], isError: true };
175+
}
176+
177+
// Remove unwanted fields from teamMember and url
178+
const simplifiedResults = {
179+
...rawResults,
180+
teamMembers: (rawResults.teamMembers || []).map((member) => {
181+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
182+
const { url, ...rest } = member;
183+
return {
184+
...rest,
185+
teamMember: member.teamMember
186+
? {
187+
displayName: member.teamMember.displayName,
188+
id: member.teamMember.id,
189+
uniqueName: member.teamMember.uniqueName,
190+
}
191+
: undefined,
192+
};
193+
}),
194+
};
195+
196+
return {
197+
content: [{ type: "text", text: JSON.stringify(simplifiedResults, null, 2) }],
198+
};
199+
} catch (error) {
200+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
201+
202+
return {
203+
content: [{ type: "text", text: `Error getting team capacity: ${errorMessage}` }],
204+
isError: true,
205+
};
206+
}
207+
}
208+
);
209+
210+
server.tool(
211+
WORK_TOOLS.update_team_capacity,
212+
"Update the team capacity of a team member for a specific iteration in a project.",
213+
{
214+
project: z.string().describe("The name or Id of the Azure DevOps project."),
215+
team: z.string().describe("The name or Id of the Azure DevOps team."),
216+
teamMemberId: z.string().describe("The team member Id for the specific team member."),
217+
iterationId: z.string().describe("The Iteration Id to update the capacity for."),
218+
activities: z
219+
.array(
220+
z.object({
221+
name: z.string().describe("The name of the activity (e.g., 'Development')."),
222+
capacityPerDay: z.number().describe("The capacity per day for this activity."),
223+
})
224+
)
225+
.describe("Array of activities and their daily capacities for the team member."),
226+
daysOff: z
227+
.array(
228+
z.object({
229+
start: z.string().describe("Start date of the day off in ISO format."),
230+
end: z.string().describe("End date of the day off in ISO format."),
231+
})
232+
)
233+
.optional()
234+
.describe("Array of days off for the team member, each with a start and end date in ISO format."),
235+
},
236+
async ({ project, team, teamMemberId, iterationId, activities, daysOff }) => {
237+
try {
238+
const connection = await connectionProvider();
239+
const workApi = await connection.getWorkApi();
240+
const teamContext = { project, team };
241+
242+
// Define interface for capacity patch
243+
interface CapacityPatch {
244+
activities: { name: string; capacityPerDay: number }[];
245+
daysOff?: { start: Date; end: Date }[];
246+
}
247+
248+
// Prepare the capacity update object
249+
const capacityPatch: CapacityPatch = {
250+
activities: activities.map((a) => ({
251+
name: a.name,
252+
capacityPerDay: a.capacityPerDay,
253+
})),
254+
daysOff: (daysOff || []).map((d) => ({
255+
start: new Date(d.start),
256+
end: new Date(d.end),
257+
})),
258+
};
259+
260+
// Update the team member's capacity
261+
const updatedCapacity = await workApi.updateCapacityWithIdentityRef(capacityPatch, teamContext, iterationId, teamMemberId);
262+
263+
if (!updatedCapacity) {
264+
return { content: [{ type: "text", text: "Failed to update team member capacity" }], isError: true };
265+
}
266+
267+
// Simplify output
268+
const simplifiedResult = {
269+
teamMember: updatedCapacity.teamMember
270+
? {
271+
displayName: updatedCapacity.teamMember.displayName,
272+
id: updatedCapacity.teamMember.id,
273+
uniqueName: updatedCapacity.teamMember.uniqueName,
274+
}
275+
: undefined,
276+
activities: updatedCapacity.activities,
277+
daysOff: updatedCapacity.daysOff,
278+
};
279+
280+
return {
281+
content: [{ type: "text", text: JSON.stringify(simplifiedResult, null, 2) }],
282+
};
283+
} catch (error) {
284+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
285+
return {
286+
content: [{ type: "text", text: `Error updating team capacity: ${errorMessage}` }],
287+
isError: true,
288+
};
289+
}
290+
}
291+
);
292+
293+
server.tool(
294+
WORK_TOOLS.get_iteration_capacities,
295+
"Get an iteration's capacity for all teams in iteration and project.",
296+
{
297+
project: z.string().describe("The name or Id of the Azure DevOps project."),
298+
iterationId: z.string().describe("The Iteration Id to get capacity for."),
299+
},
300+
async ({ project, iterationId }) => {
301+
try {
302+
const connection = await connectionProvider();
303+
const workApi = await connection.getWorkApi();
304+
305+
const rawResults = await workApi.getTotalIterationCapacities(project, iterationId);
306+
307+
if (!rawResults || !rawResults.teams || rawResults.teams.length === 0) {
308+
return { content: [{ type: "text", text: "No iteration capacity assigned to the teams" }], isError: true };
309+
}
310+
311+
return {
312+
content: [{ type: "text", text: JSON.stringify(rawResults, null, 2) }],
313+
};
314+
} catch (error) {
315+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
316+
317+
return {
318+
content: [{ type: "text", text: `Error getting iteration capacities: ${errorMessage}` }],
319+
isError: true,
320+
};
321+
}
322+
}
323+
);
153324
}
154325

155326
export { WORK_TOOLS, configureWorkTools };

0 commit comments

Comments
 (0)