Skip to content

Commit 81e3ce4

Browse files
committed
feat: implement project and team elicitation tools with user prompts
1 parent b0219bf commit 81e3ce4

File tree

7 files changed

+562
-62
lines changed

7 files changed

+562
-62
lines changed

.github/copilot-instructions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
Act like a helpful assistant, who is a professional Typescript engineer with a broad experience in LLM.
2+
23
In your work, you rigorously uphold the following guiding principles:
34

45
- **Integrity**: Act with unwavering honesty. Never distort, omit, or manipulate information.
@@ -10,3 +11,4 @@ In your work, you rigorously uphold the following guiding principles:
1011
- **Step-by-Step Reasoning**: Break down complex analyses into clear, logical steps to enhance understanding and traceability.
1112
- **Continuous Improvement**: Always seek ways to enhance the quality and reliability of your analyses by asking user for feedback and iterating on your approach.
1213
- **Tool Utilization**: Leverage available tools effectively to augment your analysis, ensuring their outputs are critically evaluated and integrated appropriately.
14+
- **Context Reuse**: When a project name, team name, or other identifier has been established in previous tool call results or user input during the conversation, reuse those values automatically in subsequent tool calls instead of leaving them blank or prompting the user again.

jest.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ module.exports = {
5151
"^(.+)/utils\\.js$": "$1/utils.ts",
5252
"^(.+)/auth\\.js$": "$1/auth.ts",
5353
"^(.+)/logger\\.js$": "$1/logger.ts",
54+
"^(.+)/elicitations\\.js$": "$1/elicitations.ts",
5455
},
5556
};

src/shared/elicitations.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5+
import { WebApi } from "azure-devops-node-api";
6+
7+
interface ElicitResolved {
8+
resolved: string;
9+
}
10+
11+
interface ElicitResponse {
12+
response: { content: { type: "text"; text: string }[]; isError?: boolean };
13+
}
14+
15+
export type ElicitResult = ElicitResolved | ElicitResponse;
16+
17+
export async function elicitProject(server: McpServer, connection: WebApi, message?: string): Promise<ElicitResult> {
18+
const coreApi = await connection.getCoreApi();
19+
const projects = await coreApi.getProjects("wellFormed", 100, 0, undefined, false);
20+
21+
if (!projects || projects.length === 0) {
22+
return { response: { content: [{ type: "text", text: "No projects found to select from." }], isError: true } };
23+
}
24+
25+
const result = await server.server.elicitInput({
26+
mode: "form",
27+
message: message ?? "Select the Azure DevOps project.",
28+
requestedSchema: {
29+
type: "object",
30+
properties: {
31+
project: {
32+
type: "string",
33+
title: "Project",
34+
description: "The Azure DevOps project.",
35+
oneOf: projects.map((p) => ({
36+
const: p.name ?? p.id ?? "",
37+
title: p.name ?? p.id ?? "Unknown project",
38+
})),
39+
},
40+
},
41+
required: ["project"],
42+
},
43+
});
44+
45+
if (result.action !== "accept" || !result.content?.project) {
46+
return { response: { content: [{ type: "text", text: "Project selection cancelled." }] } };
47+
}
48+
49+
return { resolved: String(result.content.project) };
50+
}
51+
52+
export async function elicitTeam(server: McpServer, connection: WebApi, project: string, message?: string): Promise<ElicitResult> {
53+
const coreApi = await connection.getCoreApi();
54+
const teams = await coreApi.getTeams(project, undefined, undefined, undefined, false);
55+
56+
if (!teams || teams.length === 0) {
57+
return { response: { content: [{ type: "text", text: "No teams found to select from." }], isError: true } };
58+
}
59+
60+
const result = await server.server.elicitInput({
61+
mode: "form",
62+
message: message ?? "Select the team.",
63+
requestedSchema: {
64+
type: "object",
65+
properties: {
66+
team: {
67+
type: "string",
68+
title: "Team",
69+
description: "The team from a specific Azure DevOps project.",
70+
oneOf: teams.map((t) => ({
71+
const: t.name ?? t.id ?? "",
72+
title: t.name ?? t.id ?? "Unknown team",
73+
})),
74+
},
75+
},
76+
required: ["team"],
77+
},
78+
});
79+
80+
if (result.action !== "accept" || !result.content?.team) {
81+
return { response: { content: [{ type: "text", text: "Team selection cancelled." }] } };
82+
}
83+
84+
return { resolved: String(result.content.team) };
85+
}

src/tools/core.ts

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import { WebApi } from "azure-devops-node-api";
66
import { z } from "zod";
77
import { searchIdentities } from "./auth.js";
8+
import { elicitProject } from "../shared/elicitations.js";
89

910
import type { ProjectInfo } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
1011
import { IdentityBase } from "azure-devops-node-api/interfaces/IdentitiesInterfaces.js";
@@ -25,7 +26,7 @@ function configureCoreTools(server: McpServer, tokenProvider: () => Promise<stri
2526
CORE_TOOLS.list_project_teams,
2627
"Retrieve a list of teams for an Azure DevOps project. If a project is not specified, you will be prompted to select one.",
2728
{
28-
project: z.string().optional().describe("The name or ID of the Azure DevOps project. If not provided, a project selection prompt will be shown."),
29+
project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
2930
mine: z.boolean().optional().describe("If true, only return teams that the authenticated user is a member of."),
3031
top: z.number().optional().describe("The maximum number of teams to return. Defaults to 100."),
3132
skip: z.number().optional().describe("The number of teams to skip for pagination. Defaults to 0."),
@@ -38,37 +39,9 @@ function configureCoreTools(server: McpServer, tokenProvider: () => Promise<stri
3839
let resolvedProject = project;
3940

4041
if (!resolvedProject) {
41-
const projects = await coreApi.getProjects("wellFormed", 100, 0, undefined, false);
42-
43-
if (!projects || projects.length === 0) {
44-
return { content: [{ type: "text", text: "No projects found to select from." }], isError: true };
45-
}
46-
47-
const result = await server.server.elicitInput({
48-
mode: "form",
49-
message: "Select the Azure DevOps project to list teams for.",
50-
requestedSchema: {
51-
type: "object",
52-
properties: {
53-
project: {
54-
type: "string",
55-
title: "Project",
56-
description: "The Azure DevOps project to list teams for.",
57-
oneOf: projects.map((p) => ({
58-
const: p.name ?? p.id ?? "",
59-
title: p.name ?? p.id ?? "Unknown project",
60-
})),
61-
},
62-
},
63-
required: ["project"],
64-
},
65-
});
66-
67-
if (result.action !== "accept" || !result.content?.project) {
68-
return { content: [{ type: "text", text: "Project selection cancelled." }] };
69-
}
70-
71-
resolvedProject = String(result.content.project);
42+
const result = await elicitProject(server, connection, "Select the Azure DevOps project to list teams for.");
43+
if ("response" in result) return result.response;
44+
resolvedProject = result.resolved;
7245
}
7346

7447
const teams = await coreApi.getTeams(resolvedProject, mine, top, skip, false);

src/tools/work.ts

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import { WebApi } from "azure-devops-node-api";
66
import { z } from "zod";
77
import { TreeStructureGroup, TreeNodeStructureType, WorkItemClassificationNode } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
8+
import { elicitProject, elicitTeam } from "../shared/elicitations.js";
89

910
const WORK_TOOLS = {
1011
list_team_iterations: "work_list_team_iterations",
@@ -20,24 +21,42 @@ const WORK_TOOLS = {
2021
function configureWorkTools(server: McpServer, _: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
2122
server.tool(
2223
WORK_TOOLS.list_team_iterations,
23-
"Retrieve a list of iterations for a specific team in a project.",
24+
"Retrieve a list of iterations for a specific team in a project. If a project or team is not specified, you will be prompted to select one.",
2425
{
25-
project: z.string().describe("The name or ID of the Azure DevOps project."),
26-
team: z.string().describe("The name or ID of the Azure DevOps team."),
26+
project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
27+
team: z.string().optional().describe("The name or ID of the Azure DevOps team. Reuse from prior context if already known. If not provided, a team selection prompt will be shown."),
2728
timeframe: z.enum(["current"]).optional().describe("The timeframe for which to retrieve iterations. Currently, only 'current' is supported."),
2829
},
2930
async ({ project, team, timeframe }) => {
3031
try {
3132
const connection = await connectionProvider();
33+
34+
let resolvedProject = project;
35+
if (!resolvedProject) {
36+
const result = await elicitProject(server, connection, "Select the Azure DevOps project to list team iterations for.");
37+
if ("response" in result) return result.response;
38+
resolvedProject = result.resolved;
39+
}
40+
41+
let resolvedTeam = team;
42+
if (!resolvedTeam) {
43+
const result = await elicitTeam(server, connection, resolvedProject, "Select the Azure DevOps team to list iterations for.");
44+
if ("response" in result) return result.response;
45+
resolvedTeam = result.resolved;
46+
}
47+
3248
const workApi = await connection.getWorkApi();
33-
const iterations = await workApi.getTeamIterations({ project, team }, timeframe);
49+
const iterations = await workApi.getTeamIterations({ project: resolvedProject, team: resolvedTeam }, timeframe);
3450

3551
if (!iterations) {
3652
return { content: [{ type: "text", text: "No iterations found" }], isError: true };
3753
}
3854

3955
return {
40-
content: [{ type: "text", text: JSON.stringify(iterations, null, 2) }],
56+
content: [
57+
{ type: "text", text: `Project: ${resolvedProject}, Team: ${resolvedTeam}` },
58+
{ type: "text", text: JSON.stringify(iterations, null, 2) },
59+
],
4160
};
4261
} catch (error) {
4362
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -110,23 +129,31 @@ function configureWorkTools(server: McpServer, _: () => Promise<string>, connect
110129

111130
server.tool(
112131
WORK_TOOLS.list_iterations,
113-
"List all iterations in a specified Azure DevOps project.",
132+
"List all iterations in a specified Azure DevOps project. If a project is not specified, you will be prompted to select one.",
114133
{
115-
project: z.string().describe("The name or ID of the Azure DevOps project."),
134+
project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
116135
depth: z.number().default(2).describe("Depth of children to fetch."),
117136
excludedIds: z.array(z.number()).optional().describe("An optional array of iteration IDs, and thier children, that should not be returned."),
118137
},
119138
async ({ project, depth, excludedIds: ids }) => {
120139
try {
121140
const connection = await connectionProvider();
141+
142+
let resolvedProject = project;
143+
if (!resolvedProject) {
144+
const result = await elicitProject(server, connection, "Select the Azure DevOps project to list iterations for.");
145+
if ("response" in result) return result.response;
146+
resolvedProject = result.resolved;
147+
}
148+
122149
const workItemTrackingApi = await connection.getWorkItemTrackingApi();
123150
let results = [];
124151

125152
if (depth === undefined) {
126153
depth = 1;
127154
}
128155

129-
results = await workItemTrackingApi.getClassificationNodes(project, [], depth);
156+
results = await workItemTrackingApi.getClassificationNodes(resolvedProject, [], depth);
130157

131158
// Handle null or undefined results
132159
if (!results) {
@@ -223,17 +250,25 @@ function configureWorkTools(server: McpServer, _: () => Promise<string>, connect
223250

224251
server.tool(
225252
WORK_TOOLS.get_team_capacity,
226-
"Get the team capacity of a specific team and iteration in a project.",
253+
"Get the team capacity of a specific team and iteration in a project. If a project is not specified, you will be prompted to select one.",
227254
{
228-
project: z.string().describe("The name or Id of the Azure DevOps project."),
229-
team: z.string().describe("The name or Id of the Azure DevOps team."),
255+
project: z.string().optional().describe("The name or Id of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
256+
team: z.string().describe("The name or Id of the Azure DevOps team. Reuse from prior context if already known."),
230257
iterationId: z.string().describe("The Iteration Id to get capacity for."),
231258
},
232259
async ({ project, team, iterationId }) => {
233260
try {
234261
const connection = await connectionProvider();
262+
263+
let resolvedProject = project;
264+
if (!resolvedProject) {
265+
const result = await elicitProject(server, connection, "Select the Azure DevOps project to get team capacity for.");
266+
if ("response" in result) return result.response;
267+
resolvedProject = result.resolved;
268+
}
269+
235270
const workApi = await connection.getWorkApi();
236-
const teamContext = { project, team };
271+
const teamContext = { project: resolvedProject, team };
237272

238273
const rawResults = await workApi.getCapacitiesWithIdentityRefAndTotals(teamContext, iterationId);
239274

@@ -359,17 +394,25 @@ function configureWorkTools(server: McpServer, _: () => Promise<string>, connect
359394

360395
server.tool(
361396
WORK_TOOLS.get_iteration_capacities,
362-
"Get an iteration's capacity for all teams in iteration and project.",
397+
"Get an iteration's capacity for all teams in iteration and project. If a project is not specified, you will be prompted to select one.",
363398
{
364-
project: z.string().describe("The name or Id of the Azure DevOps project."),
399+
project: z.string().optional().describe("The name or Id of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
365400
iterationId: z.string().describe("The Iteration Id to get capacity for."),
366401
},
367402
async ({ project, iterationId }) => {
368403
try {
369404
const connection = await connectionProvider();
405+
406+
let resolvedProject = project;
407+
if (!resolvedProject) {
408+
const result = await elicitProject(server, connection, "Select the Azure DevOps project to get iteration capacities for.");
409+
if ("response" in result) return result.response;
410+
resolvedProject = result.resolved;
411+
}
412+
370413
const workApi = await connection.getWorkApi();
371414

372-
const rawResults = await workApi.getTotalIterationCapacities(project, iterationId);
415+
const rawResults = await workApi.getTotalIterationCapacities(resolvedProject, iterationId);
373416

374417
if (!rawResults || !rawResults.teams || rawResults.teams.length === 0) {
375418
return { content: [{ type: "text", text: "No iteration capacity assigned to the teams" }], isError: true };
@@ -391,16 +434,31 @@ function configureWorkTools(server: McpServer, _: () => Promise<string>, connect
391434

392435
server.tool(
393436
WORK_TOOLS.get_team_settings,
394-
"Get team settings including default iteration, backlog iteration, and default area path for a team.",
437+
"Get team settings including default iteration, backlog iteration, and default area path for a team. If a project or team is not specified, you will be prompted to select one.",
395438
{
396-
project: z.string().describe("The name or ID of the Azure DevOps project."),
397-
team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."),
439+
project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
440+
team: z.string().optional().describe("The name or ID of the Azure DevOps team. Reuse from prior context if already known. If not provided, a team selection prompt will be shown."),
398441
},
399442
async ({ project, team }) => {
400443
try {
401444
const connection = await connectionProvider();
445+
446+
let resolvedProject = project;
447+
if (!resolvedProject) {
448+
const result = await elicitProject(server, connection, "Select the Azure DevOps project to get team settings for.");
449+
if ("response" in result) return result.response;
450+
resolvedProject = result.resolved;
451+
}
452+
453+
let resolvedTeam = team;
454+
if (!resolvedTeam) {
455+
const result = await elicitTeam(server, connection, resolvedProject, "Select the Azure DevOps team to get settings for.");
456+
if ("response" in result) return result.response;
457+
resolvedTeam = result.resolved;
458+
}
459+
402460
const workApi = await connection.getWorkApi();
403-
const teamContext = { project, team };
461+
const teamContext = { project: resolvedProject, team: resolvedTeam };
404462

405463
const teamSettings = await workApi.getTeamSettings(teamContext);
406464

@@ -423,7 +481,10 @@ function configureWorkTools(server: McpServer, _: () => Promise<string>, connect
423481
};
424482

425483
return {
426-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
484+
content: [
485+
{ type: "text", text: `Project: ${resolvedProject}, Team: ${resolvedTeam}` },
486+
{ type: "text", text: JSON.stringify(result, null, 2) },
487+
],
427488
};
428489
} catch (error) {
429490
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";

0 commit comments

Comments
 (0)