Skip to content

Commit 6fd5798

Browse files
Encode markdown formatted fields to prevent issues with >, <, and $ (#500)
This PR introduces an encoding utility for Markdown used in all work item creation/update paths to prevent truncation/corruption when <, >, and ${), appear in Markdown fields. ### Previously <img width="1476" height="952" alt="image" src="https://github.com/user-attachments/assets/fc15de30-1778-4034-9209-02a77b392da1" /> ### With this change <img width="1478" height="1042" alt="image" src="https://github.com/user-attachments/assets/5a6cf713-4000-47df-b6b9-4e40ce6e5bb2" /> ## GitHub issue number ## **Associated Risks** The user may intend to use `>`,`<`,`$` for their Markdown. * However, it's **signficantly** more likely the user will be using GitHub Copilot or another AI coding tool, which the models tend to use `<` and `>` to represent greater than some value (instead of intending to use it as html embedded in Markdown). ## ✅ **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?** I wrote tests and provided the following to Claude Sonnet 4 and GPT-5 both before and after this change: ```` Try creating a Markdown epic again with the following field values: tool : mcp_ado_wit_create_work_item args : { "fields": [ { "name": "System.Title", "value": "Test Epic #3 - Comparison Operators < > in Metrics" }, { "format": "Markdown", "name": "System.Description", "value": "This epic tests comparison operators in metric contexts: ## Metric Examples - Example Metric: <5 of something - Another Example Metric: >6 of something - One Last Metric: <7 of something >8 of something ## Additional Test Cases - Performance requirement: Response time <500ms - Throughput requirement: Handle >1000 requests per second - Memory usage: Keep memory <2GB but >1GB for optimal performance - Error rate: Maintain error rate <1% and uptime >99.9% ## Code Context ```bash if [ $count -lt 5 ]; then echo \"Count is <5\" elif [ $count -gt 6 ]; then echo \"Count is >6\" fi ``` Testing how these comparison operators are handled in various contexts." } ], "project": "REPLACE-ME", "workItemType": "Epic" } ```` --------- Co-authored-by: Dan Hellem <dahellem@microsoft.com>
1 parent 69b2928 commit 6fd5798

File tree

3 files changed

+61
-9
lines changed

3 files changed

+61
-9
lines changed

src/tools/work-items.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { WebApi } from "azure-devops-node-api";
77
import { WorkItemExpand, WorkItemRelation } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
88
import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
99
import { z } from "zod";
10-
import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
10+
import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert, encodeFormattedValue } from "../utils.js";
1111

1212
const WORKITEM_TOOLS = {
1313
my_work_items: "wit_my_work_items",
@@ -292,6 +292,8 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
292292
}
293293

294294
const body = items.map((item, x) => {
295+
const encodedDescription = encodeFormattedValue(item.description, item.format);
296+
295297
const ops = [
296298
{
297299
op: "add",
@@ -306,12 +308,12 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
306308
{
307309
op: "add",
308310
path: "/fields/System.Description",
309-
value: item.description,
311+
value: encodedDescription,
310312
},
311313
{
312314
op: "add",
313315
path: "/fields/Microsoft.VSTS.TCM.ReproSteps",
314-
value: item.description,
316+
value: encodedDescription,
315317
},
316318
{
317319
op: "add",
@@ -562,10 +564,10 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
562564
const connection = await connectionProvider();
563565
const workItemApi = await connection.getWorkItemTrackingApi();
564566

565-
const document = fields.map(({ name, value }) => ({
567+
const document = fields.map(({ name, value, format }) => ({
566568
op: "add",
567569
path: `/fields/${name}`,
568-
value: value,
570+
value: encodeFormattedValue(value, format),
569571
}));
570572

571573
// Check if any field has format === "Markdown" and add the multilineFieldsFormat operation
@@ -675,10 +677,10 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
675677

676678
const body = uniqueIds.map((id) => {
677679
const workItemUpdates = updates.filter((update) => update.id === id);
678-
const operations = workItemUpdates.map(({ op, path, value }) => ({
680+
const operations = workItemUpdates.map(({ op, path, value, format }) => ({
679681
op: op,
680682
path: path,
681-
value: value,
683+
value: encodeFormattedValue(value, format),
682684
}));
683685

684686
// Add format operations for Markdown fields

src/utils.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function mapStringToEnum<T extends Record<string, string | number>>(value
2727
* @param enumObject The enum object to map to
2828
* @returns Array of valid enum values
2929
*/
30-
export function mapStringArrayToEnum<T extends Record<string, string | number>>(values: string[] | undefined, enumObject: T): Array<T[keyof T]> {
30+
export function mapStringArrayToEnum<T extends Record<string, string | number>>(values: string[] | undefined, enumObject: T): T[keyof T][] {
3131
if (!values) return [];
3232
return values.map((value) => mapStringToEnum(value, enumObject)).filter((v): v is T[keyof T] => v !== undefined);
3333
}
@@ -59,3 +59,16 @@ export function safeEnumConvert<T extends Record<string, string | number>>(enumO
5959

6060
return enumObject[key as keyof T];
6161
}
62+
63+
/**
64+
* Encodes `>` and `<` for Markdown formatted fields.
65+
*
66+
* @param value The text value to encode
67+
* @param format The format of the field ('Markdown' or 'Html')
68+
* @returns The encoded text, or original text if format is not Markdown
69+
*/
70+
export function encodeFormattedValue(value: string, format?: "Markdown" | "Html"): string {
71+
if (!value || format !== "Markdown") return value;
72+
const result = value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
73+
return result;
74+
}

test/src/utils.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT License.
33

44
import { AlertType, AlertValidityStatus, Confidence, Severity, State } from "azure-devops-node-api/interfaces/AlertInterfaces";
5-
import { createEnumMapping, getEnumKeys, mapStringArrayToEnum, mapStringToEnum, safeEnumConvert } from "../../src/utils";
5+
import { createEnumMapping, encodeFormattedValue, getEnumKeys, mapStringArrayToEnum, mapStringToEnum, safeEnumConvert } from "../../src/utils";
66

77
describe("utils", () => {
88
describe("createEnumMapping", () => {
@@ -431,3 +431,40 @@ describe("utils", () => {
431431
});
432432
});
433433
});
434+
435+
describe("encodeFormattedValue", () => {
436+
describe("basic encoding behavior (always encode in Markdown)", () => {
437+
it("encodes angle brackets and dollar signs", () => {
438+
const input = "Value <x> $var > end";
439+
const result = encodeFormattedValue(input, "Markdown");
440+
expect(result).toBe("Value &lt;x&gt; $var &gt; end");
441+
});
442+
443+
it("does nothing for Html format", () => {
444+
const input = "Value <x> $var > end";
445+
const result = encodeFormattedValue(input, "Html");
446+
expect(result).toBe(input);
447+
});
448+
449+
it("returns original when format undefined", () => {
450+
const input = "<raw> $";
451+
expect(encodeFormattedValue(input)).toBe(input);
452+
});
453+
454+
it("handles empty/null/undefined", () => {
455+
expect(encodeFormattedValue("", "Markdown")).toBe("");
456+
expect(encodeFormattedValue(null as unknown as string, "Markdown")).toBe(null);
457+
expect(encodeFormattedValue(undefined as unknown as string, "Markdown")).toBe(undefined);
458+
});
459+
});
460+
461+
describe("idempotence and entity protection", () => {
462+
it("does not double encode entities", () => {
463+
const input = "Already &lt;tag&gt; plus <new> and $cash";
464+
const once = encodeFormattedValue(input, "Markdown");
465+
const twice = encodeFormattedValue(once, "Markdown");
466+
expect(once).toBe("Already &lt;tag&gt; plus &lt;new&gt; and $cash");
467+
expect(twice).toBe(once);
468+
});
469+
});
470+
});

0 commit comments

Comments
 (0)