Skip to content

Commit feae547

Browse files
authored
Add WIQL query tool and update documentation for work items (#1118)
This pull request introduces a new tool for executing WIQL (Work Item Query Language) queries against Azure DevOps work items, along with comprehensive documentation and test coverage. The main changes are the addition of the `wit_query_by_wiql` tool, updates to documentation, and new tests to ensure correct behavior and error handling. ## GitHub issue number N/A ## **Associated Risks** Poorly created WIQL could have an effect on rate limits ## ✅ **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?** ran several manual tests and generated auto tests
1 parent bd8aa9d commit feae547

4 files changed

Lines changed: 222 additions & 1 deletion

File tree

docs/TOOLSET.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
| Work Items | [mcp_ado_wit_list_backlog_work_items](#mcp_ado_wit_list_backlog_work_items) | Get work items in a backlog |
8282
| Work Items | [mcp_ado_wit_get_query](#mcp_ado_wit_get_query) | Get a work item query by ID or path |
8383
| Work Items | [mcp_ado_wit_get_query_results_by_id](#mcp_ado_wit_get_query_results_by_id) | Execute a query and get results |
84+
| Work Items | [mcp_ado_wit_query_by_wiql](#mcp_ado_wit_query_by_wiql) | Execute a WIQL query and return matching work items |
8485
| Work Items | [mcp_ado_wit_get_work_item_attachment](#mcp_ado_wit_get_work_item_attachment) | Download a work item attachment as base64 |
8586
| Work | [mcp_ado_work_list_iterations](#mcp_ado_work_list_iterations) | List all iterations in a project |
8687
| Work | [mcp_ado_work_create_iterations](#mcp_ado_work_create_iterations) | Create new iterations in a project |
@@ -670,6 +671,13 @@ Retrieve the results of a work item query given the query ID.
670671
- **Required**: `id`
671672
- **Optional**: `project`, `responseType`, `team`, `timePrecision`, `top`
672673

674+
### mcp_ado_wit_query_by_wiql
675+
676+
Execute a WIQL (Work Item Query Language) query and return the matching work items. If a project is not specified, you will be prompted to select one.
677+
678+
- **Required**: `wiql`
679+
- **Optional**: `project`, `team`, `timePrecision`, `top`
680+
673681
### mcp_ado_wit_get_work_item_attachment
674682

675683
Download a work item attachment by its ID and return the content as a base64-encoded resource. Useful for viewing images (e.g. screenshots) attached to work items such as bugs.

src/tools/work-items.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { WorkItemExpand, WorkItemRelation } from "azure-devops-node-api/interfac
77
import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
88
import { z } from "zod";
99
import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert, encodeFormattedValue } from "../utils.js";
10+
import { elicitProject } from "../shared/elicitations.js";
11+
import { createExternalContentResponse } from "../shared/content-safety.js";
1012

1113
const WORKITEM_TOOLS = {
1214
my_work_items: "wit_my_work_items",
@@ -31,6 +33,7 @@ const WORKITEM_TOOLS = {
3133
work_item_unlink: "wit_work_item_unlink",
3234
add_artifact_link: "wit_add_artifact_link",
3335
get_work_item_attachment: "wit_get_work_item_attachment",
36+
query_by_wiql: "wit_query_by_wiql",
3437
};
3538

3639
function getLinkTypeFromName(name: string) {
@@ -1267,6 +1270,43 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
12671270
}
12681271
);
12691272

1273+
server.tool(
1274+
WORKITEM_TOOLS.query_by_wiql,
1275+
"Execute a WIQL (Work Item Query Language) query and return the matching work items. If a project is not specified, you will be prompted to select one.",
1276+
{
1277+
wiql: z.string().max(32768).describe('The WIQL query string to execute, e.g., "SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.TeamProject] = @project"'),
1278+
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."),
1279+
team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team context will be used."),
1280+
timePrecision: z.boolean().optional().describe("Whether to include time precision in date fields. Defaults to false."),
1281+
top: z.coerce.number().default(50).describe("The maximum number of results to return. Defaults to 50."),
1282+
},
1283+
async ({ wiql, project, team, timePrecision, top }) => {
1284+
try {
1285+
const connection = await connectionProvider();
1286+
let resolvedProject = project;
1287+
1288+
if (!resolvedProject) {
1289+
const result = await elicitProject(server, connection, "Select the Azure DevOps project to run the WIQL query against.");
1290+
if ("response" in result) return result.response;
1291+
resolvedProject = result.resolved;
1292+
}
1293+
1294+
const workItemApi = await connection.getWorkItemTrackingApi();
1295+
const teamContext = { project: resolvedProject, team };
1296+
const queryResult = await workItemApi.queryByWiql({ query: wiql }, teamContext, timePrecision, top);
1297+
1298+
return createExternalContentResponse(queryResult, "wiql query results");
1299+
} catch (error) {
1300+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1301+
1302+
return {
1303+
content: [{ type: "text", text: `Error executing WIQL query: ${errorMessage}` }],
1304+
isError: true,
1305+
};
1306+
}
1307+
}
1308+
);
1309+
12701310
server.tool(
12711311
WORKITEM_TOOLS.get_work_item_attachment,
12721312
"Download a work item attachment by its ID and return the content as a base64-encoded resource. Useful for viewing images (e.g. screenshots) attached to work items such as bugs.",

test/mocks/work-items.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,3 +663,37 @@ export const _mockWorkItemRevisions = [
663663
url: "https://dev.azure.com/fabrikam/_apis/wit/workItems/299/revisions/2",
664664
},
665665
];
666+
667+
export const _mockWiqlQueryResults = {
668+
queryType: 1,
669+
queryResultType: 1,
670+
asOf: "2026-04-07T00:00:00.000Z",
671+
columns: [
672+
{
673+
referenceName: "System.Id",
674+
name: "ID",
675+
url: "https://dev.azure.com/fabrikam/_apis/wit/fields/System.Id",
676+
},
677+
{
678+
referenceName: "System.Title",
679+
name: "Title",
680+
url: "https://dev.azure.com/fabrikam/_apis/wit/fields/System.Title",
681+
},
682+
{
683+
referenceName: "System.State",
684+
name: "State",
685+
url: "https://dev.azure.com/fabrikam/_apis/wit/fields/System.State",
686+
},
687+
],
688+
sortColumns: [],
689+
workItems: [
690+
{
691+
id: 297,
692+
url: "https://dev.azure.com/fabrikam/_apis/wit/workItems/297",
693+
},
694+
{
695+
id: 299,
696+
url: "https://dev.azure.com/fabrikam/_apis/wit/workItems/299",
697+
},
698+
],
699+
};

test/src/tools/work-items.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
_mockBacklogs,
1212
_mockQuery,
1313
_mockQueryResults,
14+
_mockWiqlQueryResults,
1415
_mockWorkItem,
1516
_mockWorkItemComment,
1617
_mockWorkItemComments,
@@ -42,12 +43,14 @@ interface WorkItemTrackingApiMock {
4243
getWorkItemType: jest.Mock;
4344
getQuery: jest.Mock;
4445
queryById: jest.Mock;
46+
queryByWiql: jest.Mock;
4547
getAttachmentContent: jest.Mock;
4648
}
4749

4850
interface MockConnection {
4951
getWorkApi: jest.Mock;
5052
getWorkItemTrackingApi: jest.Mock;
53+
getCoreApi: jest.Mock;
5154
serverUrl?: string;
5255
}
5356

@@ -61,7 +64,7 @@ describe("configureWorkItemTools", () => {
6164
let mockWorkItemTrackingApi: WorkItemTrackingApiMock;
6265

6366
beforeEach(() => {
64-
server = { tool: jest.fn() } as unknown as McpServer;
67+
server = { tool: jest.fn(), server: { elicitInput: jest.fn() } } as unknown as McpServer;
6568
tokenProvider = jest.fn();
6669

6770
mockWorkApi = {
@@ -83,12 +86,14 @@ describe("configureWorkItemTools", () => {
8386
getWorkItemType: jest.fn(),
8487
getQuery: jest.fn(),
8588
queryById: jest.fn(),
89+
queryByWiql: jest.fn(),
8690
getAttachmentContent: jest.fn(),
8791
};
8892

8993
mockConnection = {
9094
getWorkApi: jest.fn().mockResolvedValue(mockWorkApi),
9195
getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWorkItemTrackingApi),
96+
getCoreApi: jest.fn().mockResolvedValue({ getProjects: jest.fn() }),
9297
};
9398

9499
connectionProvider = jest.fn().mockResolvedValue(mockConnection);
@@ -3749,4 +3754,138 @@ describe("configureWorkItemTools", () => {
37493754
expect(result.content[0].text).toBe("Error retrieving work item attachment: Not found");
37503755
});
37513756
});
3757+
3758+
describe("query_by_wiql tool", () => {
3759+
it("should call queryByWiql with correct params when project is provided", async () => {
3760+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
3761+
3762+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql");
3763+
if (!call) throw new Error("wit_query_by_wiql tool not registered");
3764+
const [, , , handler] = call;
3765+
3766+
(mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockResolvedValue(_mockWiqlQueryResults);
3767+
3768+
const params = {
3769+
wiql: "SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.TeamProject] = @project",
3770+
project: "Contoso",
3771+
team: undefined,
3772+
timePrecision: undefined,
3773+
top: 50,
3774+
};
3775+
3776+
const result = await handler(params);
3777+
3778+
expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith({ query: params.wiql }, { project: params.project, team: undefined }, undefined, 50);
3779+
expect(result.content[0].text).toContain("UNTRUSTED");
3780+
expect(result.content[0].text).toContain(JSON.stringify(_mockWiqlQueryResults, null, 2));
3781+
});
3782+
3783+
it("should call queryByWiql with all optional params when provided", async () => {
3784+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
3785+
3786+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql");
3787+
if (!call) throw new Error("wit_query_by_wiql tool not registered");
3788+
const [, , , handler] = call;
3789+
3790+
(mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockResolvedValue(_mockWiqlQueryResults);
3791+
3792+
const params = {
3793+
wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.State] = 'Active'",
3794+
project: "Contoso",
3795+
team: "Fabrikam",
3796+
timePrecision: true,
3797+
top: 100,
3798+
};
3799+
3800+
const result = await handler(params);
3801+
3802+
expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith({ query: params.wiql }, { project: "Contoso", team: "Fabrikam" }, true, 100);
3803+
expect(result.content[0].text).toContain("UNTRUSTED");
3804+
expect(result.content[0].text).toContain(JSON.stringify(_mockWiqlQueryResults, null, 2));
3805+
});
3806+
3807+
it("should elicit project when project is not provided and user accepts", async () => {
3808+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
3809+
3810+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql");
3811+
if (!call) throw new Error("wit_query_by_wiql tool not registered");
3812+
const [, , , handler] = call;
3813+
3814+
const mockCoreApi = { getProjects: jest.fn().mockResolvedValue([{ id: "proj-1", name: "Contoso" }]) };
3815+
(mockConnection.getCoreApi as jest.Mock).mockResolvedValue(mockCoreApi);
3816+
3817+
((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock).mockResolvedValue({
3818+
action: "accept",
3819+
content: { project: "Contoso" },
3820+
});
3821+
3822+
(mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockResolvedValue(_mockWiqlQueryResults);
3823+
3824+
const params = {
3825+
wiql: "SELECT [System.Id] FROM WorkItems",
3826+
project: undefined,
3827+
team: undefined,
3828+
timePrecision: undefined,
3829+
top: 50,
3830+
};
3831+
3832+
const result = await handler(params);
3833+
3834+
expect((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput).toHaveBeenCalled();
3835+
expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith({ query: params.wiql }, { project: "Contoso", team: undefined }, undefined, 50);
3836+
expect(result.content[0].text).toContain("UNTRUSTED");
3837+
expect(result.content[0].text).toContain(JSON.stringify(_mockWiqlQueryResults, null, 2));
3838+
});
3839+
3840+
it("should return cancellation message when user declines project elicitation", async () => {
3841+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
3842+
3843+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql");
3844+
if (!call) throw new Error("wit_query_by_wiql tool not registered");
3845+
const [, , , handler] = call;
3846+
3847+
const mockCoreApi = { getProjects: jest.fn().mockResolvedValue([{ id: "proj-1", name: "Contoso" }]) };
3848+
(mockConnection.getCoreApi as jest.Mock).mockResolvedValue(mockCoreApi);
3849+
3850+
((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock).mockResolvedValue({
3851+
action: "decline",
3852+
});
3853+
3854+
const params = {
3855+
wiql: "SELECT [System.Id] FROM WorkItems",
3856+
project: undefined,
3857+
team: undefined,
3858+
timePrecision: undefined,
3859+
top: 50,
3860+
};
3861+
3862+
const result = await handler(params);
3863+
3864+
expect(mockWorkItemTrackingApi.queryByWiql).not.toHaveBeenCalled();
3865+
expect(result.content[0].text).toBe("Project selection cancelled.");
3866+
});
3867+
3868+
it("should return an error when queryByWiql throws", async () => {
3869+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
3870+
3871+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql");
3872+
if (!call) throw new Error("wit_query_by_wiql tool not registered");
3873+
const [, , , handler] = call;
3874+
3875+
(mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockRejectedValue(new Error("WIQL syntax error"));
3876+
3877+
const params = {
3878+
wiql: "INVALID WIQL",
3879+
project: "Contoso",
3880+
team: undefined,
3881+
timePrecision: undefined,
3882+
top: 50,
3883+
};
3884+
3885+
const result = await handler(params);
3886+
3887+
expect(result.isError).toBe(true);
3888+
expect(result.content[0].text).toBe("Error executing WIQL query: WIQL syntax error");
3889+
});
3890+
});
37523891
});

0 commit comments

Comments
 (0)