npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 &gt;.
export function encodeFormattedValue(value, format) {
    if (!value || format !== "Markdown") return value;
    return value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

Install and configure

npm install @floh-solutions/ado-core

Auth is a PAT sent as HTTP Basic with an empty usernamebase64(":" + 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 default

There 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 }); // irreversible

Azure DevOps reports a refused delete as HTTP 200. With the permission missing the endpoint answers 200 OK with 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 code is 404, 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[&gt;] renders back as pre-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 &gt; everyone observed in #80/#82 is not damage the MCP did.
  • #82 is not corrupt. Read over raw REST its description contains &gt;, not &amp;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 &amp;, 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&lt;String&gt; 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 &gt; quoted emits the literal text &gt; 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 as lt[]. 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 relaunch

Gotchas encoded in this package

Each of these is a verified finding that costs a day if rediscovered.

  • System.RevisedDate is 9999-01-01 on the current revision — a sentinel meaning "not yet superseded", not a date. Storing it naively breaks date sorting. Use revisedDateOf(fields) / isRevisedDateSentinel(value).
  • …and it is not in a default read at all. A plain get returns ~20 fields; System.RevisedDate needs $expand=all (~34 fields) or an explicit fields list. Verified on #82.
  • renderedText is empty on the read path. Azure DevOps returns rendered HTML on write but not on list. Render markdown client-side. (The write response's renderedText is genuinely useful, though — it is the only proof available of what the server actually stored.)
  • The reporting feed has its own expand enumnone | fields, not the work-item none|relations|fields|links|all. Passing all is a 400.
  • A comment bumps System.Rev. A rev change does not mean a field changed.
  • System.AssignedTo is an identity object, not a string — though you may write it as an email and the server resolves it.
  • System.Parent is a plain field carrying the parent id, alongside the Hierarchy-Reverse relation. The mirror's parent_id column can come from the field without expanding relations.
  • url and 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: Suppress and maps any sign-in page it still sees to AdoAuthError, so this does not surface as a JSON parse error.
  • fields and $expand are mutually exclusive on reads — passing both throws here rather than 400ing at the server.
  • The $ in /wit/workitems/$Task is literal and must not be percent-encoded; encoding it 404s in a way that reads like a permissions problem.
  • Comment format is numeric on the way in (0 = Markdown, 1 = Html) — it is a query parameter, not a body field. On the way out at 7.2-preview.4 it 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.