Skip to content

Commit 14a25b4

Browse files
Copilotnikolapeja6kboom
authored
Fix enum schema generation to use string values instead of numeric values (#248)
## Problem When using the Azure DevOps MCP server, enum parameters in tool function declarations were generating numeric values (e.g., `0, 1, 2`) in the JSON schema, but the MCP API expects string representations (e.g., `"None", "LastModifiedAscending", "LastModifiedDescending"`). This resulted in API errors like: ``` Invalid value at 'request.tools[0].function_declarations[X].parameters.properties[Y].value.enum[Z]' (TYPE_STRING), [numeric_value] ``` The issue affected several tools including: - `build_get_definitions` (queryOrder parameter) - `build_get_builds` (queryOrder parameter) - `build_update_build_stage` (status parameter) - `release_get_definitions` (expand, queryOrder parameters) - `release_get_releases` (statusFilter, queryOrder, expand parameters) ## Root Cause The issue was caused by using `z.nativeEnum()` with TypeScript numeric enums from the `azure-devops-node-api` package. When `zod-to-json-schema` processes `z.nativeEnum()`, it generates: ```json { "type": "number", "enum": [0, 1, 2, 3, 4] } ``` But the MCP protocol expects: ```json { "type": "string", "enum": ["None", "LastModifiedAscending", "LastModifiedDescending", "DefinitionNameAscending", "DefinitionNameDescending"] } ``` ## Solution 1. **Added utility function**: Created `getEnumKeys()` in `utils.ts` to extract string keys from TypeScript numeric enums 2. **Replaced z.nativeEnum**: Updated all enum parameters in `builds.ts` and `releases.ts` to use `z.enum(getEnumKeys(EnumType))` instead of `z.nativeEnum(EnumType)` 3. **Maintained API compatibility**: Updated tool handlers to convert string enum values back to numeric values when calling Azure DevOps APIs 4. **Added comprehensive tests**: Created tests to verify enum schemas generate the correct string types and values ## Changes ### Files Modified: - `src/utils.ts` - Added `getEnumKeys()` utility function - `src/tools/builds.ts` - Replaced 3 instances of `z.nativeEnum()` with string-based enums - `src/tools/releases.ts` - Replaced 5 instances of `z.nativeEnum()` with string-based enums - `test/src/tools/builds.test.ts` - Updated tests to use string enum values - `test/src/enum-schema.test.ts` - Added comprehensive enum schema validation tests ### Before/After Comparison: **Before (generates numeric schema):** ```typescript queryOrder: z.nativeEnum(DefinitionQueryOrder).optional() ``` **After (generates string schema):** ```typescript queryOrder: z.enum(getEnumKeys(DefinitionQueryOrder) as [string, ...string[]]).optional() ``` The tool handlers now properly convert string values back to numeric for API calls: ```typescript queryOrder ? DefinitionQueryOrder[queryOrder as keyof typeof DefinitionQueryOrder] : undefined ``` ## Testing - All existing tests pass - New tests verify enum schemas generate string types with correct values - Manual verification confirms schemas now generate `"type": "string"` instead of `"type": "number"` - Build and linting pass successfully Fixes #183 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: nikolapeja6 <[email protected]> Co-authored-by: kboom <[email protected]> Co-authored-by: Nikola Pejic <[email protected]>
1 parent 3ae0fe5 commit 14a25b4

File tree

8 files changed

+224
-41
lines changed

8 files changed

+224
-41
lines changed

src/tools/builds.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { AccessToken } from "@azure/identity";
55
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6-
import { apiVersion } from "../utils.js";
6+
import { apiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
77
import { WebApi } from "azure-devops-node-api";
88
import { BuildQueryOrder, DefinitionQueryOrder } from "azure-devops-node-api/interfaces/BuildInterfaces.js";
99
import { z } from "zod";
@@ -31,7 +31,10 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
3131
repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter build definitions"),
3232
name: z.string().optional().describe("Name of the build definition to filter"),
3333
path: z.string().optional().describe("Path of the build definition to filter"),
34-
queryOrder: z.nativeEnum(DefinitionQueryOrder).optional().describe("Order in which build definitions are returned"),
34+
queryOrder: z
35+
.enum(getEnumKeys(DefinitionQueryOrder) as [string, ...string[]])
36+
.optional()
37+
.describe("Order in which build definitions are returned"),
3538
top: z.number().optional().describe("Maximum number of build definitions to return"),
3639
continuationToken: z.string().optional().describe("Token for continuing paged results"),
3740
minMetricsTime: z.coerce.date().optional().describe("Minimum metrics time to filter build definitions"),
@@ -70,7 +73,7 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
7073
name,
7174
repositoryId,
7275
repositoryType,
73-
queryOrder,
76+
safeEnumConvert(DefinitionQueryOrder, queryOrder),
7477
top,
7578
continuationToken,
7679
minMetricsTime,
@@ -129,7 +132,11 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
129132
continuationToken: z.string().optional().describe("Token for continuing paged results"),
130133
maxBuildsPerDefinition: z.number().optional().describe("Maximum number of builds per definition"),
131134
deletedFilter: z.number().optional().describe("Filter for deleted builds (see QueryDeletedOption enum)"),
132-
queryOrder: z.nativeEnum(BuildQueryOrder).default(BuildQueryOrder.QueueTimeDescending).optional().describe("Order in which builds are returned"),
135+
queryOrder: z
136+
.enum(getEnumKeys(BuildQueryOrder) as [string, ...string[]])
137+
.default("QueueTimeDescending")
138+
.optional()
139+
.describe("Order in which builds are returned"),
133140
branchName: z.string().optional().describe("Branch name to filter builds"),
134141
buildIds: z.array(z.number()).optional().describe("Array of build IDs to retrieve"),
135142
repositoryId: z.string().optional().describe("Repository ID to filter builds"),
@@ -177,7 +184,7 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
177184
continuationToken,
178185
maxBuildsPerDefinition,
179186
deletedFilter,
180-
queryOrder,
187+
safeEnumConvert(BuildQueryOrder, queryOrder),
181188
branchName,
182189
buildIds,
183190
repositoryId,
@@ -314,7 +321,7 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
314321
project: z.string().describe("Project ID or name to update the build stage for"),
315322
buildId: z.number().describe("ID of the build to update"),
316323
stageName: z.string().describe("Name of the stage to update"),
317-
status: z.nativeEnum(StageUpdateType).describe("New status for the stage"),
324+
status: z.enum(getEnumKeys(StageUpdateType) as [string, ...string[]]).describe("New status for the stage"),
318325
forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage."),
319326
},
320327
async ({ project, buildId, stageName, status, forceRetryAllJobs }) => {
@@ -325,7 +332,7 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
325332

326333
const body = {
327334
forceRetryAllJobs: forceRetryAllJobs,
328-
state: status.valueOf(),
335+
state: safeEnumConvert(StageUpdateType, status),
329336
};
330337

331338
const response = await fetch(endpoint, {

src/tools/releases.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { WebApi } from "azure-devops-node-api";
77
import { ReleaseDefinitionExpands, ReleaseDefinitionQueryOrder, ReleaseExpands, ReleaseStatus, ReleaseQueryOrder } from "azure-devops-node-api/interfaces/ReleaseInterfaces.js";
88
import { z } from "zod";
9+
import { getEnumKeys, safeEnumConvert } from "../utils.js";
910

1011
const RELEASE_TOOLS = {
1112
get_release_definitions: "release_get_definitions",
@@ -19,12 +20,18 @@ function configureReleaseTools(server: McpServer, tokenProvider: () => Promise<A
1920
{
2021
project: z.string().describe("Project ID or name to get release definitions for"),
2122
searchText: z.string().optional().describe("Search text to filter release definitions"),
22-
expand: z.nativeEnum(ReleaseDefinitionExpands).default(ReleaseDefinitionExpands.None).describe("Expand options for release definitions"),
23+
expand: z
24+
.enum(getEnumKeys(ReleaseDefinitionExpands) as [string, ...string[]])
25+
.default("None")
26+
.describe("Expand options for release definitions"),
2327
artifactType: z.string().optional().describe("Filter by artifact type"),
2428
artifactSourceId: z.string().optional().describe("Filter by artifact source ID"),
2529
top: z.number().optional().describe("Number of results to return (for pagination)"),
2630
continuationToken: z.string().optional().describe("Continuation token for pagination"),
27-
queryOrder: z.nativeEnum(ReleaseDefinitionQueryOrder).default(ReleaseDefinitionQueryOrder.NameAscending).describe("Order of the results"),
31+
queryOrder: z
32+
.enum(getEnumKeys(ReleaseDefinitionQueryOrder) as [string, ...string[]])
33+
.default("NameAscending")
34+
.describe("Order of the results"),
2835
path: z.string().optional().describe("Path to filter release definitions"),
2936
isExactNameMatch: z.boolean().optional().default(false).describe("Whether to match the exact name of the release definition. Default is false."),
3037
tagFilter: z.array(z.string()).optional().describe("Filter by tags associated with the release definitions"),
@@ -55,12 +62,12 @@ function configureReleaseTools(server: McpServer, tokenProvider: () => Promise<A
5562
const releaseDefinitions = await releaseApi.getReleaseDefinitions(
5663
project,
5764
searchText,
58-
expand,
65+
safeEnumConvert(ReleaseDefinitionExpands, expand),
5966
artifactType,
6067
artifactSourceId,
6168
top,
6269
continuationToken,
63-
queryOrder,
70+
safeEnumConvert(ReleaseDefinitionQueryOrder, queryOrder),
6471
path,
6572
isExactNameMatch,
6673
tagFilter,
@@ -85,7 +92,11 @@ function configureReleaseTools(server: McpServer, tokenProvider: () => Promise<A
8592
definitionEnvironmentId: z.number().optional().describe("ID of the definition environment to filter releases"),
8693
searchText: z.string().optional().describe("Search text to filter releases"),
8794
createdBy: z.string().optional().describe("User ID or name who created the release"),
88-
statusFilter: z.nativeEnum(ReleaseStatus).optional().default(ReleaseStatus.Active).describe("Status of the releases to filter (default: Active)"),
95+
statusFilter: z
96+
.enum(getEnumKeys(ReleaseStatus) as [string, ...string[]])
97+
.optional()
98+
.default("Active")
99+
.describe("Status of the releases to filter (default: Active)"),
89100
environmentStatusFilter: z.number().optional().describe("Environment status to filter releases"),
90101
minCreatedTime: z.coerce
91102
.date()
@@ -101,10 +112,18 @@ function configureReleaseTools(server: McpServer, tokenProvider: () => Promise<A
101112
.optional()
102113
.default(() => new Date())
103114
.describe("Maximum created time for releases (default: now)"),
104-
queryOrder: z.nativeEnum(ReleaseQueryOrder).optional().default(ReleaseQueryOrder.Ascending).describe("Order in which to return releases (default: Ascending)"),
115+
queryOrder: z
116+
.enum(getEnumKeys(ReleaseQueryOrder) as [string, ...string[]])
117+
.optional()
118+
.default("Ascending")
119+
.describe("Order in which to return releases (default: Ascending)"),
105120
top: z.number().optional().describe("Number of releases to return"),
106121
continuationToken: z.number().optional().describe("Continuation token for pagination"),
107-
expand: z.nativeEnum(ReleaseExpands).optional().default(ReleaseExpands.None).describe("Expand options for releases"),
122+
expand: z
123+
.enum(getEnumKeys(ReleaseExpands) as [string, ...string[]])
124+
.optional()
125+
.default("None")
126+
.describe("Expand options for releases"),
108127
artifactTypeId: z.string().optional().describe("Filter releases by artifact type ID"),
109128
sourceId: z.string().optional().describe("Filter releases by artifact source ID"),
110129
artifactVersionId: z.string().optional().describe("Filter releases by artifact version ID"),
@@ -147,14 +166,14 @@ function configureReleaseTools(server: McpServer, tokenProvider: () => Promise<A
147166
definitionEnvironmentId,
148167
searchText,
149168
createdBy,
150-
statusFilter,
169+
safeEnumConvert(ReleaseStatus, statusFilter),
151170
environmentStatusFilter,
152171
minCreatedTime,
153172
maxCreatedTime,
154-
queryOrder,
173+
safeEnumConvert(ReleaseQueryOrder, queryOrder),
155174
top,
156175
continuationToken,
157-
expand,
176+
safeEnumConvert(ReleaseExpands, expand),
158177
artifactTypeId,
159178
sourceId,
160179
artifactVersionId,

src/tools/repos.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { z } from "zod";
1818
import { getCurrentUserDetails } from "./auth.js";
1919
import { GitRepository } from "azure-devops-node-api/interfaces/TfvcInterfaces.js";
20+
import { getEnumKeys } from "../utils.js";
2021

2122
const REPO_TOOLS = {
2223
list_repos_by_project: "repo_list_repos_by_project",
@@ -49,15 +50,15 @@ function branchesFilterOutIrrelevantProperties(branches: GitRef[], top: number)
4950

5051
function pullRequestStatusStringToInt(status: string): number {
5152
switch (status) {
52-
case "abandoned":
53+
case "Abandoned":
5354
return PullRequestStatus.Abandoned.valueOf();
54-
case "active":
55+
case "Active":
5556
return PullRequestStatus.Active.valueOf();
56-
case "all":
57+
case "All":
5758
return PullRequestStatus.All.valueOf();
58-
case "completed":
59+
case "Completed":
5960
return PullRequestStatus.Completed.valueOf();
60-
case "notSet":
61+
case "NotSet":
6162
return PullRequestStatus.NotSet.valueOf();
6263
default:
6364
throw new Error(`Unknown pull request status: ${status}`);
@@ -113,12 +114,12 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
113114
{
114115
repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
115116
pullRequestId: z.number().describe("The ID of the pull request to be published."),
116-
status: z.enum(["active", "abandoned"]).describe("The new status of the pull request. Can be 'active' or 'abandoned'."),
117+
status: z.enum(["Active", "Abandoned"]).describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."),
117118
},
118119
async ({ repositoryId, pullRequestId, status }) => {
119120
const connection = await connectionProvider();
120121
const gitApi = await connection.getGitApi();
121-
const statusValue = status === "active" ? 3 : 2;
122+
const statusValue = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf();
122123

123124
const updatedPullRequest = await gitApi.updatePullRequest({ status: statusValue }, repositoryId, pullRequestId);
124125

@@ -208,7 +209,10 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
208209
skip: z.number().default(0).describe("The number of pull requests to skip."),
209210
created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
210211
i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."),
211-
status: z.enum(["abandoned", "active", "all", "completed", "notSet"]).default("active").describe("Filter pull requests by status. Defaults to 'active'."),
212+
status: z
213+
.enum(getEnumKeys(PullRequestStatus) as [string, ...string[]])
214+
.default("Active")
215+
.describe("Filter pull requests by status. Defaults to 'Active'."),
212216
},
213217
async ({ repositoryId, top, skip, created_by_me, i_am_reviewer, status }) => {
214218
const connection = await connectionProvider();
@@ -274,7 +278,10 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
274278
skip: z.number().default(0).describe("The number of pull requests to skip."),
275279
created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
276280
i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."),
277-
status: z.enum(["abandoned", "active", "all", "completed", "notSet"]).default("active").describe("Filter pull requests by status. Defaults to 'active'."),
281+
status: z
282+
.enum(getEnumKeys(PullRequestStatus) as [string, ...string[]])
283+
.default("Active")
284+
.describe("Filter pull requests by status. Defaults to 'Active'."),
278285
},
279286
async ({ project, top, skip, created_by_me, i_am_reviewer, status }) => {
280287
const connection = await connectionProvider();

src/tools/workitems.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,18 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { WebApi } from "azure-devops-node-api";
77
import { WorkItemExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
88
import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
9+
import { Operation } from "azure-devops-node-api/interfaces/common/VSSInterfaces.js";
910
import { z } from "zod";
10-
import { batchApiVersion, markdownCommentsApiVersion } from "../utils.js";
11+
import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
12+
13+
/**
14+
* Converts Operation enum key to lowercase string for API usage
15+
* @param operation The Operation enum key (e.g., "Add", "Replace", "Remove")
16+
* @returns Lowercase string for API usage (e.g., "add", "replace", "remove")
17+
*/
18+
function operationToApiString(operation: string): string {
19+
return operation.toLowerCase();
20+
}
1121

1222
const WORKITEM_TOOLS = {
1323
my_work_items: "wit_my_work_items",
@@ -454,17 +464,24 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
454464
updates: z
455465
.array(
456466
z.object({
457-
op: z.enum(["add", "replace", "remove"]).default("add").describe("The operation to perform on the field."),
467+
op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
458468
path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
459-
value: z.string().describe("The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations."),
469+
value: z.string().describe("The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations."),
460470
})
461471
)
462472
.describe("An array of field updates to apply to the work item."),
463473
},
464474
async ({ id, updates }) => {
465475
const connection = await connectionProvider();
466476
const workItemApi = await connection.getWorkItemTrackingApi();
467-
const updatedWorkItem = await workItemApi.updateWorkItem(null, updates, id);
477+
478+
// Convert operation names to lowercase for API
479+
const apiUpdates = updates.map((update) => ({
480+
...update,
481+
op: operationToApiString(update.op),
482+
}));
483+
484+
const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id);
468485

469486
return {
470487
content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }],
@@ -557,7 +574,10 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
557574
{
558575
project: z.string().describe("The name or ID of the Azure DevOps project."),
559576
query: z.string().describe("The ID or path of the query to retrieve."),
560-
expand: z.enum(["all", "clauses", "minimal", "none", "wiql"]).optional().describe("Optional expand parameter to include additional details in the response. Defaults to 'none'."),
577+
expand: z
578+
.enum(getEnumKeys(QueryExpand) as [string, ...string[]])
579+
.optional()
580+
.describe("Optional expand parameter to include additional details in the response. Defaults to 'None'."),
561581
depth: z.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."),
562582
includeDeleted: z.boolean().default(false).describe("Whether to include deleted items in the query results. Defaults to false."),
563583
useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."),
@@ -566,7 +586,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
566586
const connection = await connectionProvider();
567587
const workItemApi = await connection.getWorkItemTrackingApi();
568588

569-
const queryDetails = await workItemApi.getQuery(project, query, expand as unknown as QueryExpand, depth, includeDeleted, useIsoDateFormat);
589+
const queryDetails = await workItemApi.getQuery(project, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat);
570590

571591
return {
572592
content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }],
@@ -603,7 +623,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
603623
updates: z
604624
.array(
605625
z.object({
606-
op: z.enum(["add", "replace", "remove"]).default("add").describe("The operation to perform on the field."),
626+
op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
607627
id: z.number().describe("The ID of the work item to update."),
608628
path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
609629
value: z.string().describe("The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations."),
@@ -632,7 +652,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
632652
workItemUpdates.forEach(({ path, value, format }) => {
633653
if (format === "Markdown" && value && value.length > 50) {
634654
operations.push({
635-
op: "add",
655+
op: "Add",
636656
path: `/multilineFieldsFormat${path.replace("/fields", "")}`,
637657
value: "Markdown",
638658
});

0 commit comments

Comments
 (0)