Repository: microsoft/azure-devops-mcp

Issue:
Encode markdown formatted fields by default
# Summary
Encode HTML characters `>`, `<`, and additionally `$` to their corresponding `&gt;`, `&lt;`, and `&#36;`. The models (GPT-5 and Claude Sonnet 4, as an example) will use these characters without realizing that they will cause issue in Markdown, sometimes making it seem like parts of these field were truncated, instead of having malformed Markdown. Claude Sonnet also tends to turn `&gt;` and `&lt;` into `>` and `<` when explicitly requesting the model to encode these fields (or add the fields as-is with the encoding).

# Example

(There should be <6 of something and >8 of something, the codeblock is missing lines as well)

<img width="1476" height="952" alt="Image" src="https://github.com/user-attachments/assets/33c00f6c-b48a-446f-8c90-0da8f3574808" />

# Repro Steps

1. Use the azure-devops-mcp server with GitHub Copilot and Claude Sonnet 4 or GPT-5.
2. Ask it to use the mcp_ado_wit_create_work_item to create an Epic wth a Markdown formatted description. Ask it to add a list of metrics showing less than and greater than some metrics.

## Expected

The Epic is made with a Markdown description and shows a list of metrics with `>` and `<` values.

## Actual

The Markdown interpretter produces inconsistent content for the field leading to confusion.



Discussion:
@drk-mtr: Although not related to character encoding, it may be relevant to note here that (as far as I know) Azure DevOps does not use proper spec-compliant HTML for lists.

In "Azure DevOps modified HTML", nested `ol` elements appear _after_ the parent `li` element, rather than being contained within it. When converting markdown to HTML, the conversion needs to be to "Azure DevOps modified HTML" not "proper HTML", else lists exhibit some very frustrating behaviour when you try to modify them within DevOps.

I was using the following prompt to address this when I was writing a tool that covered a limited subset of the Azure DevOps MCP server:

```md
1. The acceptance criteria input will be defined using markdown, and this should be translated to a specialised form of HTML that we will call "Azure DevOps Modified HTML". This will be done in two steps:
   1. Step 1 - Convert the markdown to HTML with the following rules:
      1. Always use ordered lists for numbered items i.e. always `<ol>` and never `<ul>`.
      1. Apply the following styles to lists (`<ol>` tags):
         - Use an inline style of `list-style:decimal` for all top level lists except the first one (levels 4, 7, 10 etc.)
         - Do not use any inline styles for the top level list (level 1)
         - Use an inlie style of `list-style:lower-alpha` for the second level of lists (levels 2, 5, 8, etc.)
         - Use an inline style of `list-style:lower-roman` for the third level (3, 6, 9, etc.)
      1. For markdown input that is not a list, do not convert it to a list, but instead convert it to a paragraph using `<p>` tags.
      1. When converting acceptance criteria written in Gherkin syntax (GIVEN / WHEN / THEN), do not use ordered or unordered list tags. Instead, format each step as a separate `<div>` element, and do not use `<p>` elements inside the `<div>` elements. Only use `<ol>` for actual numbered lists, not for scenario steps.
   1. Step 2 - Apply specific formatting rules to the HTML to translate it into "Azure DevOps Modified HTML".
      1. Reformat lists such that a nested list `ol` element will be moved AFTER the parent list `<li>` element, instead of being nested inside it, as follows:
         - This source HTML:
         ```html
         <ol>
           <li>
             Parent item
             <ol>
               <li>Child item 1</li>
               <li>Child item 2</li>
             </ol>
           </li>
           <li>Another parent item</li>
         </ol>
         ```
         - Will be transformed to:
         ```html
         <ol>
           <li>Parent item</li>
           <ol>
             <li>Child item 1</li>
             <li>Child item 2</li>
           </ol>
           <li>Another parent item</li>
         </ol>
         ```
      1. When transforming lists to "Azure DevOps Modified HTML", always move each nested <ol> directly after its containing <li> element, not after the parent <ol>. This rule must be strictly enforced for all acceptance criteria formatting.
      1. If the input is not a list, do not perform any further conversion.
```

Solution patch:
diff --git a/src/tools/work-items.ts b/src/tools/work-items.ts
index cbc1a09..4399ad9 100644
--- a/src/tools/work-items.ts
+++ b/src/tools/work-items.ts
@@ -7,7 +7,7 @@ import { WebApi } from "azure-devops-node-api";
 import { WorkItemExpand, WorkItemRelation } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
 import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
 import { z } from "zod";
-import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
+import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert, encodeFormattedValue } from "../utils.js";
 
 const WORKITEM_TOOLS = {
   my_work_items: "wit_my_work_items",
@@ -292,6 +292,8 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
         }
 
         const body = items.map((item, x) => {
+          const encodedDescription = encodeFormattedValue(item.description, item.format);
+
           const ops = [
             {
               op: "add",
@@ -306,12 +308,12 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
             {
               op: "add",
               path: "/fields/System.Description",
-              value: item.description,
+              value: encodedDescription,
             },
             {
               op: "add",
               path: "/fields/Microsoft.VSTS.TCM.ReproSteps",
-              value: item.description,
+              value: encodedDescription,
             },
             {
               op: "add",
@@ -562,10 +564,10 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
         const connection = await connectionProvider();
         const workItemApi = await connection.getWorkItemTrackingApi();
 
-        const document = fields.map(({ name, value }) => ({
+        const document = fields.map(({ name, value, format }) => ({
           op: "add",
           path: `/fields/${name}`,
-          value: value,
+          value: encodeFormattedValue(value, format),
         }));
 
         // Check if any field has format === "Markdown" and add the multilineFieldsFormat operation
@@ -675,10 +677,10 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
 
       const body = uniqueIds.map((id) => {
         const workItemUpdates = updates.filter((update) => update.id === id);
-        const operations = workItemUpdates.map(({ op, path, value }) => ({
+        const operations = workItemUpdates.map(({ op, path, value, format }) => ({
           op: op,
           path: path,
-          value: value,
+          value: encodeFormattedValue(value, format),
         }));
 
         // Add format operations for Markdown fields
diff --git a/src/utils.ts b/src/utils.ts
index 48ae145..a46a3dd 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -27,7 +27,7 @@ export function mapStringToEnum<T extends Record<string, string | number>>(value
  * @param enumObject The enum object to map to
  * @returns Array of valid enum values
  */
-export function mapStringArrayToEnum<T extends Record<string, string | number>>(values: string[] | undefined, enumObject: T): Array<T[keyof T]> {
+export function mapStringArrayToEnum<T extends Record<string, string | number>>(values: string[] | undefined, enumObject: T): T[keyof T][] {
   if (!values) return [];
   return values.map((value) => mapStringToEnum(value, enumObject)).filter((v): v is T[keyof T] => v !== undefined);
 }
@@ -59,3 +59,16 @@ export function safeEnumConvert<T extends Record<string, string | number>>(enumO
 
   return enumObject[key as keyof T];
 }
+
+/**
+ * Encodes `>` and `<` for Markdown formatted fields.
+ *
+ * @param value The text value to encode
+ * @param format The format of the field ('Markdown' or 'Html')
+ * @returns The encoded text, or original text if format is not Markdown
+ */
+export function encodeFormattedValue(value: string, format?: "Markdown" | "Html"): string {
+  if (!value || format !== "Markdown") return value;
+  const result = value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
+  return result;
+}



Test patch:
diff --git a/test/src/utils.test.ts b/test/src/utils.test.ts
index 4712d49..390994f 100644
--- a/test/src/utils.test.ts
+++ b/test/src/utils.test.ts
@@ -2,7 +2,7 @@
 // Licensed under the MIT License.
 
 import { AlertType, AlertValidityStatus, Confidence, Severity, State } from "azure-devops-node-api/interfaces/AlertInterfaces";
-import { createEnumMapping, getEnumKeys, mapStringArrayToEnum, mapStringToEnum, safeEnumConvert } from "../../src/utils";
+import { createEnumMapping, encodeFormattedValue, getEnumKeys, mapStringArrayToEnum, mapStringToEnum, safeEnumConvert } from "../../src/utils";
 
 describe("utils", () => {
   describe("createEnumMapping", () => {
@@ -431,3 +431,40 @@ describe("utils", () => {
     });
   });
 });
+
+describe("encodeFormattedValue", () => {
+  describe("basic encoding behavior (always encode in Markdown)", () => {
+    it("encodes angle brackets and dollar signs", () => {
+      const input = "Value <x> $var > end";
+      const result = encodeFormattedValue(input, "Markdown");
+      expect(result).toBe("Value &lt;x&gt; $var &gt; end");
+    });
+
+    it("does nothing for Html format", () => {
+      const input = "Value <x> $var > end";
+      const result = encodeFormattedValue(input, "Html");
+      expect(result).toBe(input);
+    });
+
+    it("returns original when format undefined", () => {
+      const input = "<raw> $";
+      expect(encodeFormattedValue(input)).toBe(input);
+    });
+
+    it("handles empty/null/undefined", () => {
+      expect(encodeFormattedValue("", "Markdown")).toBe("");
+      expect(encodeFormattedValue(null as unknown as string, "Markdown")).toBe(null);
+      expect(encodeFormattedValue(undefined as unknown as string, "Markdown")).toBe(undefined);
+    });
+  });
+
+  describe("idempotence and entity protection", () => {
+    it("does not double encode entities", () => {
+      const input = "Already &lt;tag&gt; plus <new> and $cash";
+      const once = encodeFormattedValue(input, "Markdown");
+      const twice = encodeFormattedValue(once, "Markdown");
+      expect(once).toBe("Already &lt;tag&gt; plus &lt;new&gt; and $cash");
+      expect(twice).toBe(once);
+    });
+  });
+});