@floh-solutions/ado-core
v0.11.0
Published
Thin Azure DevOps REST client covering the four gaps in @azure-devops/mcp: attachment upload, delete/restore, the test op on /rev, and unmangled Markdown.
Downloads
417
Readme
@floh-solutions/ado-core
A thin TypeScript REST client for Azure DevOps work items.
It exists because Microsoft's @azure-devops/mcp server cannot do four things
this project needs. Everything else — reading, querying, the board tools —
should still go through the MCP server; this package is for the gaps and for the
write path.
Route all description writes through this package rather than the MCP server. See gap 4.
The four gaps
Verified on 2026-08-03 against @azure-devops/mcp's shipped dist/, not
just its observed behaviour.
| # | Gap | Evidence | Covered by |
|---|---|---|---|
| 1 | No attachment upload | wit_work_item_attachment takes an attachmentId and saves locally — nothing sends bytes | client.attachments.upload / .attach / .uploadAndAttach |
| 2 | No delete at all | tool list is wit_work_item{,_write,_attachment,_comment_write,_link_write}, wit_query, wit_backlog — no delete, no recycle bin | client.workItems.delete / .restore |
| 3 | No test op | dist/tools/work-items.js pipes the op through z.enum(["add","replace","remove"]), so {op:"test",path:"/rev"} is rejected by zod before it reaches Azure DevOps | client.workItems.updateWithRev |
| 4 | Markdown </> mangled | encodeFormattedValue() in dist/utils.js escapes </> only when format === "Markdown" | setMarkdown |
Gap 3 is the important one: every write through the MCP server is last-write-wins and can silently clobber a teammate.
Gap 4 is an MCP bug, not an Azure DevOps behaviour — the same value written over REST round-trips intact:
// @azure-devops/mcp dist/utils.js — escaping is backwards; markdown is exactly
// where `>` is meaningful, so blockquotes render as literal >.
export function encodeFormattedValue(value, format) {
if (!value || format !== "Markdown") return value;
return value.replace(/</g, "<").replace(/>/g, ">");
}Install and configure
npm install @floh-solutions/ado-coreAuth is a PAT sent as HTTP Basic with an empty username — base64(":" + PAT).
Read from the environment and nowhere else, so this works headless. Never commit
a real token.
ADO_PAT=… # required. scopes: vso.work_write, and vso.wiki_write for the wiki
ADO_ORG=… # required. deliberately no default
ADO_PROJECT=… # required. deliberately no defaultThere is no built-in organisation on purpose: a default would let a misconfigured run succeed quietly against somebody else's board instead of failing where the mistake was made.
import { AdoClient } from "@floh-solutions/ado-core";
const client = AdoClient.fromEnv();The four gaps, in use
1 — Attachment upload
Two steps, both here (PLAN.md §2). Attachments are immutable: re-uploading makes a new one, so a downloaded file can be cached by GUID forever.
const { attachment } = await client.attachments.uploadAndAttach(
82,
"whiteboard.png",
await readFile("whiteboard.png"),
{ comment: "standup photo" },
);Use attachments for what isn't in git — screenshots, photos, PDFs. Plans belong
in the wiki, and code references are Hyperlink relations to a blob URL pinned
at a SHA (addHyperlink).
2 — Delete and restore
await client.workItems.delete(83); // → recycle bin, reversible
await client.workItems.restore(83); // ← back again
await client.workItems.delete(83, { destroy: true }); // irreversibleAzure DevOps reports a refused delete as HTTP 200. With the permission missing the endpoint answers
200 OKwith the failure in the body:{"id":105,"code":404,"message":"VS403145: Insufficient permissions to delete work item 105."}. Taking the status at face value reports a delete that never happened — this client checks the body and throws.The account this was built against does not currently have this permission (PLAN.md §9 lists it as an outstanding admin grant), so gap 2 is implemented and unit tested but cannot be exercised there yet. Note the
codeis404, so a 404 from this endpoint is not evidence the item is missing.
3 — The test op on /rev
const item = await client.workItems.get(82);
try {
await client.workItems.updateWithRev(82, item.rev, [
setField("System.State", "Doing"),
]);
} catch (error) {
if (error instanceof AdoConcurrencyError) {
// Nothing was written. `error.current` is the server's copy.
}
}The guard op is prepended, so Azure DevOps aborts the whole patch before applying anything.
Confirmed live 2026-08-03 — a failed guard answers:
HTTP 412 Precondition Failed
{
"message": "VS403351: Test Operation for path /rev failed, value 2 was not equal to test value 1.",
"typeKey": "TestPatchOperationFailedException",
"eventId": 3000
}and the work item is verifiably unchanged.
Conflict detection does not depend on Azure DevOps' error wording, which is
undocumented and version-dependent. On a 400/409/412 the client re-reads the item
and compares revs; only a genuine mismatch becomes an AdoConcurrencyError. A
rule violation that happens to return 400 stays the error it was.
A rev mismatch is not proof of an edit collision. Posting a comment bumps
System.Rev on its own (#82 went 3 → 4 from a comment alone), so a teammate
merely commenting invalidates your cached rev. Use fieldsTouchedBy to check
whether anything you actually wrote moved:
const contested = fieldsTouchedBy(ops).filter(
(field) => error.current?.fields[field] !== expected[field],
);
if (contested.length === 0) {
// Somebody commented. Refresh the rev and retry — this is not a conflict.
}4 — Markdown without the mangling
await client.workItems.update(82, [
...setMarkdown("System.Description", "> quoted\n\n`Array<String>`"),
]);setMarkdown returns two ops: the value, and the format declaration, which
is a sibling op in the same patch rather than a request parameter:
[
{ "op": "add", "path": "/fields/System.Description", "value": "> quoted\n\n`Array<String>`" },
{ "op": "add", "path": "/multilineFieldsFormat/System.Description", "value": "Markdown" }
]The value is passed through verbatim. Nothing is escaped — that is the point.
What the live run changed about this gap
Gap 4's premise did not survive contact with the real API. Verified 2026-08-03:
- Azure DevOps decodes HTML entities on write. A field written as
pre-escaped[>]renders back aspre-escaped[>]— the server turned the entity into the character. So the MCP server's escaping is neutralised by the server for the blockquote case, and the>everyone observed in #80/#82 is not damage the MCP did. - #82 is not corrupt. Read over raw REST its description contains
>, not&gt;. Single-encoded means the stored character is>. Nothing needs repairing. - The read path is what mangles. Reading any long-text field back gives it
to you HTML-encoded — and an
&you wrote comes back&, which is what proves the encoding is applied on the way out, not on the way in.
So both the MCP and this package write markdown correctly. What actually bites
is reading it back — see decodeFieldText below.
Gap 4 still matters, but for a narrower reason: escaping before the server
sees the value protects tag-shaped text, so Array<String> sent raw over REST
can be eaten by the sanitizer on the read path where Array<String>
survives.
Read and write disagree on capitalisation. A read returns
multilineFieldsFormat as a sibling of fields with lowercase values
("markdown"); the write op requires capitalised ("Markdown"). Comparing
them with === silently never matches — use multilineFormatOf(item, field),
which normalises to the write form.
A format op cannot stand alone. {op:add, path:/multilineFieldsFormat/X}
with no accompanying value op is rejected with "The type changed without a
value." — which is why setMarkdown always emits the pair.
Reading long text back: decodeFieldText
const source = decodeFieldText(String(item.fields["System.Description"] ?? ""));The mirror renders markdown client-side (PLAN.md §6), and a markdown renderer
fed > quoted emits the literal text > quoted, not a blockquote. Decode
first.
This does not recover everything. The read path also runs an HTML sanitizer, so tag-shaped runs are dropped rather than encoded — a field written as
lt[<] gt[>]reads back aslt[]. Entities are recoverable; stripped tags are not. The stored value is fine either way (the server's own rendering proves it), so where exact source text matters, keep your own copy rather than round-tripping it through a read.
Also here, for the downstream tracks
// WIQL — returns ids only, so this queries then hydrates via workitemsbatch
const items = await client.wiql.queryAndFetch(
"SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Doing'",
{ fields: ["System.Id", "System.Title", "System.State"] },
);
// Batch reads, chunked at the 200-id server limit automatically
await client.workItems.getBatch(ids, { expand: "all" });
// Comments. `format` is a NUMERIC query param: 0 = Markdown, 1 = Html.
await client.comments.add(82, "> markdown comment");
await client.comments.listAll(82);
// The reporting-revisions feed — poll ~3s focused / ~30s unfocused
let token = await store.watermark();
for await (const page of client.reporting.iterate({ continuationToken: token })) {
await store.apply(page.values);
token = page.continuationToken;
}
await store.setWatermark(token); // resume here, no full resync on relaunchGotchas encoded in this package
Each of these is a verified finding that costs a day if rediscovered.
System.RevisedDateis9999-01-01on the current revision — a sentinel meaning "not yet superseded", not a date. Storing it naively breaks date sorting. UserevisedDateOf(fields)/isRevisedDateSentinel(value).- …and it is not in a default read at all. A plain
getreturns ~20 fields;System.RevisedDateneeds$expand=all(~34 fields) or an explicitfieldslist. Verified on #82. renderedTextis empty on the read path. Azure DevOps returns rendered HTML on write but not onlist. Render markdown client-side. (The write response'srenderedTextis genuinely useful, though — it is the only proof available of what the server actually stored.)- The reporting feed has its own expand enum —
none | fields, not the work-itemnone|relations|fields|links|all. Passingallis a 400. - A comment bumps
System.Rev. A rev change does not mean a field changed. System.AssignedTois an identity object, not a string — though you may write it as an email and the server resolves it.System.Parentis a plain field carrying the parent id, alongside theHierarchy-Reverserelation. The mirror'sparent_idcolumn can come from the field without expanding relations.urland relation URLs use the project GUID, not the project name, so never string-compare them against a name-built URL.- A bad PAT gets an HTML sign-in page, not a 401. The client sends
X-TFS-FedAuthRedirect: Suppressand maps any sign-in page it still sees toAdoAuthError, so this does not surface as a JSON parse error. fieldsand$expandare mutually exclusive on reads — passing both throws here rather than 400ing at the server.- The
$in/wit/workitems/$Taskis literal and must not be percent-encoded; encoding it 404s in a way that reads like a permissions problem. - Comment
formatis numeric on the way in (0 = Markdown, 1 = Html) — it is a query parameter, not a body field. On the way out at7.2-preview.4it comes back as the string"markdown". - A delete refusal arrives as HTTP 200. See gap 2 above.
Retries
429s are always replayed (a throttled request provably never ran, so even a
create is safe). 5xx and dead sockets are replayed only for reads — a 5xx is
ambiguous about whether the write landed, and replaying a create would
double-post a comment or duplicate an attachment. Retry-After is honoured.
Tests
pnpm test # 92 tests against a mocked fetch. Offline. This is the gate.
pnpm typecheck
ADO_LIVE=1 pnpm test # + 11 live tests against the configured project (needs ADO_PAT)The live suite is skipped unless ADO_LIVE=1, so pnpm test works with no
network and no credentials.
Anything it writes is prefixed [SMOKETEST] and assigned to [email protected]
so no teammate is notified. Work items 80 / 81 / 82 are never touched; they
are queued for deletion under board todo #527.
Cleanup currently fails, and says so. afterAll tries to remove everything
it created, but delete is denied for this account, so it prints the surviving
ids with direct links for manual removal rather than claiming success. Do not
run the live suite casually — every run leaves ~7 work items behind until the
delete permission is granted.
