Skip to content

Commit f61093a

Browse files
authored
feat: enhance project team retrieval tool with project selection prompt (#990)
This pull request enhances the usability of the `core_list_project_teams` tool in the Azure DevOps integration by allowing users to select a project interactively when one is not specified. It also adds comprehensive tests to verify the new user interaction flow and edge cases. The most important changes are grouped below by theme. ## GitHub issue number N/A ## **Associated Risks** Should be low risk ## ✅ **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 - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Tested manually and updated manual tests
1 parent b21018e commit f61093a

File tree

2 files changed

+143
-4
lines changed

2 files changed

+143
-4
lines changed

src/tools/core.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ function filterProjectsByName(projects: ProjectInfo[], projectNameFilter: string
2323
function configureCoreTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
2424
server.tool(
2525
CORE_TOOLS.list_project_teams,
26-
"Retrieve a list of teams for the specified Azure DevOps project.",
26+
"Retrieve a list of teams for an Azure DevOps project. If a project is not specified, you will be prompted to select one.",
2727
{
28-
project: z.string().describe("The name or ID of the Azure DevOps project."),
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."),
2929
mine: z.boolean().optional().describe("If true, only return teams that the authenticated user is a member of."),
3030
top: z.number().optional().describe("The maximum number of teams to return. Defaults to 100."),
3131
skip: z.number().optional().describe("The number of teams to skip for pagination. Defaults to 0."),
@@ -34,7 +34,44 @@ function configureCoreTools(server: McpServer, tokenProvider: () => Promise<stri
3434
try {
3535
const connection = await connectionProvider();
3636
const coreApi = await connection.getCoreApi();
37-
const teams = await coreApi.getTeams(project, mine, top, skip, false);
37+
38+
let resolvedProject = project;
39+
40+
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);
72+
}
73+
74+
const teams = await coreApi.getTeams(resolvedProject, mine, top, skip, false);
3875

3976
if (!teams) {
4077
return { content: [{ type: "text", text: "No teams found" }], isError: true };

test/src/tools/core.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe("configureCoreTools", () => {
2323
let mockCoreApi: CoreApiMock;
2424

2525
beforeEach(() => {
26-
server = { tool: jest.fn() } as unknown as McpServer;
26+
server = { tool: jest.fn(), server: { elicitInput: jest.fn() } } as unknown as McpServer;
2727
tokenProvider = jest.fn();
2828
userAgentProvider = () => "Jest";
2929

@@ -416,6 +416,108 @@ describe("configureCoreTools", () => {
416416
expect(result.isError).toBe(true);
417417
expect(result.content[0].text).toContain("Error fetching project teams: Unknown error occurred");
418418
});
419+
420+
it("should elicit project when project is not provided and user accepts", async () => {
421+
configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider);
422+
423+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams");
424+
if (!call) throw new Error("core_list_project_teams tool not registered");
425+
const [, , , handler] = call;
426+
427+
(mockCoreApi.getProjects as jest.Mock).mockResolvedValue([
428+
{ id: "proj-1", name: "ProjectAlpha" },
429+
{ id: "proj-2", name: "ProjectBeta" },
430+
]);
431+
432+
((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock).mockResolvedValue({
433+
action: "accept",
434+
content: { project: "ProjectAlpha" },
435+
});
436+
437+
(mockCoreApi.getTeams as jest.Mock).mockResolvedValue([{ id: "team-1", name: "Team One" }]);
438+
439+
const params = { project: undefined, mine: undefined, top: undefined, skip: undefined };
440+
const result = await handler(params);
441+
442+
expect(mockCoreApi.getProjects).toHaveBeenCalledWith("wellFormed", 100, 0, undefined, false);
443+
expect((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput).toHaveBeenCalled();
444+
expect(mockCoreApi.getTeams).toHaveBeenCalledWith("ProjectAlpha", undefined, undefined, undefined, false);
445+
expect(result.content[0].text).toContain("Team One");
446+
expect(result.isError).toBeUndefined();
447+
});
448+
449+
it("should return cancellation message when user declines elicitation", async () => {
450+
configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider);
451+
452+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams");
453+
if (!call) throw new Error("core_list_project_teams tool not registered");
454+
const [, , , handler] = call;
455+
456+
(mockCoreApi.getProjects as jest.Mock).mockResolvedValue([{ id: "proj-1", name: "ProjectAlpha" }]);
457+
458+
((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock).mockResolvedValue({
459+
action: "decline",
460+
});
461+
462+
const params = { project: undefined, mine: undefined, top: undefined, skip: undefined };
463+
const result = await handler(params);
464+
465+
expect(mockCoreApi.getTeams).not.toHaveBeenCalled();
466+
expect(result.content[0].text).toBe("Project selection cancelled.");
467+
});
468+
469+
it("should fall back to project id when name is missing in elicitation options", async () => {
470+
configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider);
471+
472+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams");
473+
if (!call) throw new Error("core_list_project_teams tool not registered");
474+
const [, , , handler] = call;
475+
476+
(mockCoreApi.getProjects as jest.Mock).mockResolvedValue([
477+
{ id: "proj-1", name: undefined },
478+
{ id: undefined, name: undefined },
479+
]);
480+
481+
const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock;
482+
elicitMock.mockResolvedValue({
483+
action: "accept",
484+
content: { project: "proj-1" },
485+
});
486+
487+
(mockCoreApi.getTeams as jest.Mock).mockResolvedValue([{ id: "team-1", name: "Team One" }]);
488+
489+
const params = { project: undefined, mine: undefined, top: undefined, skip: undefined };
490+
const result = await handler(params);
491+
492+
const schema = elicitMock.mock.calls[0][0].requestedSchema;
493+
const oneOf = schema.properties.project.oneOf;
494+
495+
// Project with no name falls back to id
496+
expect(oneOf[0]).toEqual({ const: "proj-1", title: "proj-1" });
497+
// Project with neither name nor id falls back to "" and "Unknown project"
498+
expect(oneOf[1]).toEqual({ const: "", title: "Unknown project" });
499+
500+
expect(mockCoreApi.getTeams).toHaveBeenCalledWith("proj-1", undefined, undefined, undefined, false);
501+
expect(result.content[0].text).toContain("Team One");
502+
});
503+
504+
it("should return error when no projects are available for elicitation", async () => {
505+
configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider);
506+
507+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams");
508+
if (!call) throw new Error("core_list_project_teams tool not registered");
509+
const [, , , handler] = call;
510+
511+
(mockCoreApi.getProjects as jest.Mock).mockResolvedValue([]);
512+
513+
const params = { project: undefined, mine: undefined, top: undefined, skip: undefined };
514+
const result = await handler(params);
515+
516+
expect((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput).not.toHaveBeenCalled();
517+
expect(mockCoreApi.getTeams).not.toHaveBeenCalled();
518+
expect(result.isError).toBe(true);
519+
expect(result.content[0].text).toBe("No projects found to select from.");
520+
});
419521
});
420522

421523
describe("get_identity_ids tool", () => {

0 commit comments

Comments
 (0)