DEV Community

Cover image for One client, four registries: PyPI, npm, Packagist and MCP all disagree
Koray KÖYLÜ
Koray KÖYLÜ

Posted on Originally published at ibanchecker.cash

One client, four registries: PyPI, npm, Packagist and MCP all disagree

We ship one small API client in several languages. The code is nearly identical each time: five methods, typed models, no runtime dependencies. Publishing it was different in every ecosystem, and not in the ways the documentation prepares you for. PyPI, npm, Packagist and the MCP Registry each have a different idea of what it means to prove a release came from you, and two of them have a trap that will cost you an afternoon.

This is what actually happened, with version numbers and error strings, so you can skip the parts that cost us time.

Why PyPI is the easiest

Because PyPI lets you describe the publisher before the package exists. You register a pending publisher against a repository, a workflow filename and an environment, and the first release from that workflow creates the project. No token is ever generated, stored or rotated.

name: publish
on:
  release:
    types: [published]
permissions:
  contents: read
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write   # Trusted Publishing (OIDC), no token stored
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.x"
      - run: python -m pip install --upgrade build
      - run: python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1
Enter fullscreen mode Exit fullscreen mode

Create the GitHub environment named in environment: before you cut the release, or the job fails on a missing environment rather than on anything to do with publishing.

Why npm cannot do the same thing

npm supports OIDC, but it has no equivalent of a pending publisher, so OIDC cannot perform a package's first publish. Version 0.1.0 has to go out with a manual npm publish, and only then can trust attach to something that exists. This is a CLI limitation, tracked as npm/cli issue 8544, and the published documentation does not mention it.

Configuring trust afterwards has its own edge. On npm 11.11.0 the command returns a bare 400 Bad Request with no error body:

npm trust github --file publish.yml --repository owner/repo --environment npm
# 400 Bad Request
Enter fullscreen mode Exit fullscreen mode

A registry rule introduced on 20 May 2026 requires the trust configuration to name an allowed action, and that npm version predates the flag that expresses it. Newer versions say so in plain words. If you cannot upgrade globally, run the newer CLI directly:

npx -y npm@11.19.0 trust github \
  --file publish.yml --repository owner/repo \
  --environment npm --allow-publish
Enter fullscreen mode Exit fullscreen mode

One more practical detail: the npm CLI redacts two-factor approval URLs when it does not think it is attached to a terminal, so in CI-style output you get a censored link you cannot open. Allocate a pty and the real URL appears:

printf '\n' | script -q /dev/null npm publish
Enter fullscreen mode Exit fullscreen mode

Those links expire within minutes, so only run commands that trigger them when you are actually at the keyboard.

Why Packagist needs no workflow at all

Because it does not publish anything. You submit the repository URL once, and from then on every tag matching v* becomes a version. There is no OIDC, no token, no publish.yml.

The consequence is a rule that catches people coming from npm: your composer.json must not contain a version field. The tag is the single source of truth, and a hardcoded version will fight it.

{
  "name": "ibanchecker/client",
  "require": { "php": "^8.0", "ext-curl": "*", "ext-json": "*" }
}
Enter fullscreen mode Exit fullscreen mode

We expected to have to install a webhook manually, because the account was registered by email rather than by connecting GitHub. Packagist installed it itself, and the package page reads "auto-updated" without anyone touching it.

What is different about the MCP Registry

The MCP Registry is young enough that its manifest schema still moves. A server.json that validated last month can be rejected this month for a renamed field. Validate before you publish rather than after:

mcp-publisher validate
Enter fullscreen mode Exit fullscreen mode

The payoff is that registry listings propagate: one MCP Registry entry is picked up by the directories that index it, so a small server ends up discoverable in several places from a single publish.

The trap that wastes the most time: checking whether a name is free

Before any of this you need a package name, and the obvious way to check PyPI is to open the project page. Do not. That URL returns 200 OK for names that do not exist, because an unauthenticated request can be served a bot challenge with a success status. You will conclude the name is taken and pick a worse one.

Compare a name that exists with one nobody owns:

# a name that exists
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/project/ibanchecker/      # 200
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/pypi/ibanchecker/json     # 200

# a name that does not exist
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/project/zzz-not-taken-xyz/   # 200  <- lies
curl -o /dev/null -w '%{http_code}\n' https://pypi.org/pypi/zzz-not-taken-xyz/json  # 404  <- truth
Enter fullscreen mode Exit fullscreen mode

Use the JSON endpoint, or /simple/<name>/. Both answer honestly.

How the four compare

Registry First publish Later releases Secret stored
PyPI OIDC, via a pending publisher OIDC None
npm Manual, OIDC cannot OIDC once trust is set None after setup
Packagist Submit the repo URL Any v* tag None
MCP Registry mcp-publisher Same None

The through-line is that none of the four needs a long-lived token any more, and every one of them gets there by a different route. If you are shipping to more than one, budget the time for npm and leave PyPI until last, because it is the one that will just work.

The worked example

If you want to read a small, boring, dependency-free implementation of the same client four times: ibanchecker on PyPI, ibanchecker/client on Packagist, @ibanchecker/client on npm, and @ibanchecker/mcp for the MCP server. They call the free IBAN validation API documented at ibanchecker.cash/api-docs.

I also wrote up the npm 400 in more detail, if that is the part you hit: npm trust github fails with 400 Bad Request.``

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.