# Publishing to npm

Everything in this repo is ready to publish as **`ai-usage-mcp`** (name confirmed available on
the public registry). The only step that cannot be automated is `npm login`, which is
interactive.

Once published, anyone gets it with:

```bash
claude mcp add ai-usage -- npx -y ai-usage-mcp     # no install at all
# or
npm install -g ai-usage-mcp && claude mcp add ai-usage -- ai-usage-mcp
```

---

## One-time setup

1. Create an account at <https://www.npmjs.com/signup> (skip if you have one).
2. Log in from this machine:

```bash
npm login              # opens a browser; asks for your 2FA code if enabled
npm whoami             # must print your username
```

3. Optional but recommended — add repository metadata so the npm page links back to your
   source. Run this once you have a GitHub repo, substituting your URL:

```bash
npm pkg set \
  repository.type="git" \
  repository.url="git+https://github.com/<you>/ai-usage-mcp.git" \
  bugs.url="https://github.com/<you>/ai-usage-mcp/issues" \
  homepage="https://github.com/<you>/ai-usage-mcp#readme" \
  author="<your name>"
```

Publishing works without these; they only affect the package page.

---

## Pre-flight

```bash
npm whoami                    # logged in?
npm view ai-usage-mcp version # "404 Not Found" means the name is still yours to take
npm pack --dry-run            # review exactly what will ship
```

`npm pack --dry-run` should list `dist/`, `docs/`, `README.md`, `LICENSE`, `package.json` —
(about 215 kB packed and roughly 150 files at 0.14.0; the numbers drift, what matters is what
is absent). It must **not** contain `src/`, `tests/`, `node_modules/`, or any
`.db` file. That is enforced by the `files` array in `package.json`.

A `prepublishOnly` hook runs `npm run check` (typecheck, lint, format, build and the whole test
suite) automatically, so a publish cannot succeed with anything failing.

---

## First publish (done once, by hand)

Only the very first release was published this way. Every later one is tag-driven, see
[Later releases](#later-releases).

```bash
npm publish
```

Unscoped packages are public by default. That single command runs `npm run check`, then uploads.

If you ever switch to a scoped name (`@your-org/ai-usage-mcp`), the first publish needs
`npm publish --access public`, or it goes out private and nobody can install it.

---

## Verify it actually works for a stranger

```bash
npm view ai-usage-mcp                       # appears on the registry

# install exactly as a new user would, into a throwaway prefix
npm install -g --prefix /tmp/verify ai-usage-mcp
/tmp/verify/bin/ai-usage --version
/tmp/verify/bin/ai-usage status

# and the zero-install path
npx -y -p ai-usage-mcp ai-usage --version
```

Then register it and confirm the MCP handshake:

```bash
claude mcp add ai-usage -- npx -y ai-usage-mcp
claude mcp list
# then run /mcp inside Claude Code
```

---

## Later releases

**A release is a pushed `v*` tag, and nothing else.** Pushing to `main` runs CI only.
`.github/workflows/release.yml` does the publishing; do not run `npm publish` by hand, or it
races the workflow.

1. **Branch** off `main` and bump the version in every file that carries it
   (`package.json` and `package-lock.json`, `server.json` twice, `.claude-plugin/plugin.json`),
   as in [Version agreement](#version-agreement) below.
2. **Update `CHANGELOG.md`.** Move the `Unreleased` entries under `## [X.Y.Z] - YYYY-MM-DD`,
   in Added / Changed / Fixed order. The workflow takes the GitHub Release notes from that
   exact heading and fails if it is missing. Then fix the footer links: `[Unreleased]` compares
   `vX.Y.Z...HEAD`, and add `[X.Y.Z]` comparing the previous tag to `vX.Y.Z`.
   Then set `releaseHighlight` in `package.json` to one line on what the release adds, for
   example `{ "version": "X.Y.Z", "text": "status shows which Claude Code hooks are on" }`
   (at most 160 characters). An outdated install's update notice quotes it, from both `status`
   and the MCP server. That is the only way someone on an older version learns why to update,
   so name the feature, not the version. The reader drops a highlight whose `version` is not
   the release's own, and `tests/plugin/manifest.test.ts` fails on one. Remove the field if
   the release has nothing worth announcing.
3. **`npm run check`, open a PR, let CI go green, merge.** Pick the version by what is in the
   section: any new feature is a minor bump, a fix on its own is a patch.
4. **Tag the merge commit on an up-to-date `main`, with an _annotated_ tag, and push it:**

   ```bash
   git checkout main && git pull --ff-only
   git tag -a vX.Y.Z -m "vX.Y.Z"
   git push origin vX.Y.Z
   ```

5. **Watch it:** `gh run list --workflow release.yml`, then `gh run watch <id>`.

**The tag must be annotated.** `git push --follow-tags` silently skips a lightweight tag, so a
release can look done (tag exists locally, `main` pushed) while nothing happens. If a release
seems not to fire, check `git cat-file -t vX.Y.Z` (want `tag`, not `commit`) and
`git ls-remote --tags origin vX.Y.Z` before looking at the workflow.

What the workflow does, in order: `npm run check`; checks that the tag, `package.json`,
`server.json` and the plugin manifest all agree; publishes to npm with provenance (skipped if
that version is already on the registry); creates the GitHub Release from the changelog
section; then publishes to the MCP registry.

**npm trusted publishing is configured on npmjs.com**, not in this repo: the package is bound
to `MohitBansal321/ai-usage-mcp`, workflow filename `release.yml`, environment `release`. No
`NPM_TOKEN` is used, and that binding is what produces the provenance badge. Renaming the
workflow file or changing its `environment:` silently breaks publishing until the npmjs.com
binding is updated to match. GitHub runs the workflow as it exists at the tagged commit, so a
workflow change never applies retroactively to an older tag.

The version the MCP server advertises is read from `package.json` at runtime, so there is no
constant in `src/` to keep in sync. `server.json` and `.claude-plugin/plugin.json` **are** two
further places the version lives.

---

## Publishing to the MCP registry

The official MCP registry (`registry.modelcontextprotocol.io`) is what feeds PulseMCP, Glama,
mcp.so and the other directories. Two files drive it:

- `package.json` → `"mcpName": "io.github.MohitBansal321/ai-usage-mcp"`
- `server.json` at the repo root — the registry entry itself.

The `registry` job in `.github/workflows/release.yml` does the publish on a `v*` tag, using
`mcp-publisher login github-oidc`. There is no secret: the OIDC token is exchanged for a
registry JWT granting `io.github.<repository_owner>/*`, which is why the job needs
`id-token: write`.

### Ordering constraint: npm first, always

**The registry verifies package ownership by downloading the published npm tarball and
reading `mcpName` out of the `package.json` inside it.** It does not read this repository. So:

1. A **new npm version must be published first**, and it must be a version whose tarball
   already contains `mcpName`. Version 0.4.1 was published before `mcpName` existed, so
   **0.5.0 is the first release the registry can accept** -- its tarball is the first to
   carry `mcpName`.
2. Only then can `mcp-publisher publish` succeed.

That is exactly why the registry publish is a `needs: publish` job inside `release.yml`
rather than a separate tag-triggered workflow — a second workflow would start in parallel
with the npm publish and fail validation against a version npm is not serving yet. The job
also polls `npm view` until the new version is visible, because npm's read path lags the
write by a few seconds.

### Version agreement

Three files carry the version and all of them must agree with `package.json`:

| File                         | Field(s)                         | Why it matters if it drifts                                                                      |
| ---------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------ |
| `server.json`                | `version`, `packages[0].version` | The registry rejects a mismatch                                                                  |
| `.claude-plugin/plugin.json` | `version`                        | Setting it pins the plugin, so a stale value means installed plugin users stop receiving updates |

The release workflow fails fast if any of them drift, alongside the existing
tag-vs-package.json check, and `tests/plugin/manifest.test.ts` asserts the plugin version in
`npm run check` — so a forgotten bump is caught locally, before you tag. A version bump is:

```bash
npm version X.Y.Z --no-git-tag-version          # package.json and package-lock.json
# then mirror it into server.json and the plugin manifest
node -e '
  const fs=require("fs");
  const v=require("./package.json").version;
  const s=JSON.parse(fs.readFileSync("server.json","utf8"));
  s.version=v; s.packages[0].version=v;
  fs.writeFileSync("server.json", JSON.stringify(s,null,2)+"\n");
  const p=JSON.parse(fs.readFileSync(".claude-plugin/plugin.json","utf8"));
  p.version=v;
  fs.writeFileSync(".claude-plugin/plugin.json", JSON.stringify(p,null,2)+"\n");
'
npx prettier --write server.json .claude-plugin/plugin.json
```

**The `prettier --write` is not optional.** `JSON.stringify(…, null, 2)` puts every array
element on its own line, while Prettier keeps short arrays such as `keywords` and
`args: ["-y", "ai-usage-mcp"]` inline — so without it `npm run check` fails on `format:check`
immediately after a version bump. This bit the 0.6.0 release.

The plugin is **not** published to npm — `files` in `package.json` excludes `.claude-plugin/`
and `commands/`, so the tarball stays exactly as it was. Claude Code installs the plugin from
this Git repository, which means a plugin user gets the manifest at whatever tag or branch they
resolved, while the MCP server itself still comes from npm via `npx -y ai-usage-mcp`.

### Namespace casing is significant

The server name is `io.github.MohitBansal321/ai-usage-mcp` — matching the GitHub account's
canonical casing exactly, **not** lowercased. GitHub OIDC grants
`io.github.<repository_owner>/*` using the claim verbatim, and the registry's permission check
is a case-sensitive `strings.HasPrefix`
([`internal/auth/jwt.go`](https://github.com/modelcontextprotocol/registry/blob/main/internal/auth/jwt.go)).
A lowercase `io.github.mohitbansal321/...` would be granted nothing and fail with a 403. This
is a known open bug — [registry#689](https://github.com/modelcontextprotocol/registry/issues/689)
— and the maintainers' guidance there is to match your account's casing.

Because `mcpName` ships inside an immutable npm tarball, getting this wrong costs a whole npm
version to correct. Do not "tidy" the casing.

### Validating before you tag

```bash
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
./mcp-publisher validate server.json
```

### Confirming it landed

```bash
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.MohitBansal321/ai-usage-mcp"
```

---

## What cannot be undone

Treat a publish as permanent:

- **A version number can never be reused**, even after unpublishing. Publish `0.1.0` once.
- **Unpublishing is only allowed within 72 hours**, and only if nothing depends on it. After
  that the best you can do is `npm deprecate`.
- **The name is claimed** as soon as you publish.

If you want a rehearsal first, publish a prerelease that nobody will install by default:

```bash
npm version 0.1.0-rc.0 --no-git-tag-version
npm publish --tag next          # installable only via ai-usage-mcp@next
```

---

## Why installs won't break for users

Storage uses Node's built-in **`node:sqlite`**, so a normal install compiles nothing and
needs no toolchain, no Python and no particular npm. `better-sqlite3` is retained only as an
`optionalDependencies` fallback for hosts whose Node predates 22.13.0 (where `node:sqlite` is
still behind `--experimental-sqlite` and has no `iterate()`); npm skips it silently when it
cannot be built. `ai-usage status` prints which driver is live.

The rest of this section is the history of why that changed, kept because it explains the
constraint.

`better-sqlite3` v13 ships **prebuilt N-API binaries inside its own npm tarball** for 8
platforms:

```text
darwin-arm64  darwin-x64        (macOS Apple Silicon + Intel)
linux-arm64   linux-x64         (glibc)
linuxmusl-arm64  linuxmusl-x64  (Alpine / musl)
win32-arm64   win32-x64         (Windows)
```

It declares `gypfile: false` and has no `install` script, so npm should never invoke a
compiler and never needs to reach GitHub for a binary. This matters: v11 of that library used
`prebuild-install`, which fetched binaries from GitHub releases at install time and had **no
build for Node 24 at all** — users on current Node would have needed Python and a C++
toolchain, and the install would fail without them.

**One exception, found by CI:** npm 10 **on Windows** ignores `gypfile: false` and runs
`node-gyp rebuild` anyway, which then fails on a machine with no toolchain. The same npm 10 on
macOS and Linux does not, and npm 11 does not on any platform. The bundled Windows prebuild is
perfectly usable — installing with `--ignore-scripts` on Windows + Node 22 loads and runs it
correctly. So this is an npm-version behaviour, not a packaging defect.

That used to be why `package.json` declared `engines.npm >= 11` and why CI raised npm to 11 on
every matrix leg.

**That is now fixed the way this section recommended:** the native dependency is no longer
required. `engines.npm` is gone, CI no longer touches the runner's npm — so an npm 10 Windows
install is now a genuine regression test — and `engines.node` is `>=22.13.0`, the release that
unflagged `node:sqlite` and added `StatementSync.prototype.iterate()`.

If you later need to support Node 20, do not downgrade the dependency — switch the storage
layer to Node's built-in `node:sqlite` instead.
