Skip to content

Releases: Consiliency/pmcp

v2.7.3

Choose a tag to compare

@github-actions github-actions released this 31 Aug 00:23
v2.7.3
91b8aed

Changed

  • PMCP no longer implies it verified where a server-supplied auth URL
    points.
    It never did: _is_public_auth_host classifies IP literals and
    accepts a DNS name without resolving it, so
    https://metadata.google.internal/... in an elicitation payload or a
    WWW-Authenticate header was relayed to the operator — and to an agent —
    looking like something PMCP had checked. Names are still relayed, because
    refusing unresolvable ones would refuse a well-behaved server's
    https://auth.vendor.com/... too; what changed is what PMCP claims about
    them. UrlElicitationInfo.url_verified, AuthMetadataInfo.verified_urls
    (per URL field, since those five URLs are independent) and
    AuthChallengeInfo.resource_metadata_url_verified report whether PMCP itself
    classified the host as a public literal, and all three default to
    unverified. The qualification is threaded into the next_step string an
    agent actually follows, and into pmcp auth connect / pmcp auth acknowledge
    output in both human text and --json. pmcp doctor no longer reports a bare
    [OK] ... configured at <name> for a host it did not verify
    (#211).
  • A downstream server can no longer hand back a loopback http:// URL.
    sanitize_url_elicitation_url now splits by provenance: a URL parsed out of a
    server's error payload loses loopback HTTP, while one the operator types into
    gateway.auth_connect keeps it, because local OAuth redirects to
    http://127.0.0.1. The default is the strict remote policy, so a call site
    missed by a future change fails closed (#211).
  • fetch_json_metadata now fails closed. PMCP retrieves that URL itself, so
    "accepted but unresolved" is not good enough there: the host must be one PMCP
    verified as a public IP literal, and a refused URL is rejected before the
    opener is reached rather than fetched and judged afterwards. http://127.0.0.1/x
    and unresolved names previously both reached urlopen (#211).

v2.7.2

Choose a tag to compare

@github-actions github-actions released this 30 Aug 18:18
v2.7.2
5b7f767

Fixed

  • The public auth-URL host check accepted several non-public hosts. Two
    distinct defects in _is_public_auth_host, both reachable through auth
    metadata, elicitation, and resource_server_jwks_url:

    • The classifier subtracted a list of bad properties (is_private,
      is_loopback, is_link_local, is_multicast, is_unspecified), and that
      list had holes. RFC 6598 CGNAT (100.64.0.0/10) and deprecated RFC 3879
      IPv6 site-local (fec0::/10) were classified as public, as were IPv4
      addresses embedded in IPv6 literals — 64:ff9b::7f00:1 (RFC 6052 NAT64
      carrying 127.0.0.1), ::10.0.0.5 (RFC 4291 IPv4-compatible) and
      ::0:5efe:a00:5 (RFC 5214 ISATAP). The check now unwraps every
      IPv4-embedding IPv6 format and then classifies positively.
    • Legacy numeric host forms bypassed the check entirely. ip_address()
      raises on them, and the code read "raised" as "this is a DNS name, accept
      it" — while a stock resolver reads 2852039166 and 0xA9FEA9FE as
      169.254.169.254 and 0177.0.0.1 as 127.0.0.1, with no DNS lookup
      involved. Such hosts are now canonicalised (not resolved) and classified as
      the literals they are.

    Genuinely public addresses are unaffected, including public addresses carried
    inside an embedding format (::ffff:8.8.8.8, 64:ff9b::808:808) and addresses
    that merely resemble one: ISATAP is matched on its full RFC 5214 §6.1
    interface identifier (00-00-5E-FE, or 02-00-5E-FE with the u/g bit set),
    not on the 5efe hextet alone, so an ordinary global address such as
    2606:4700::1234:5efe:a00:5 is still accepted.

    Two limitations remain, both name-shaped and both tracked in #211: a DNS name
    is accepted without being resolved, and so is a trailing-dot IPv4 such as
    169.254.169.254., which POSIX inet_aton also rejects as an address. The
    DNS-name limitation is now stated in the docstring, README.md and
    SECURITY.md instead of being implied away. The error message no longer claims
    the host "must be public", since only IP literals are ever checked. (#210)

v2.7.1

Choose a tag to compare

@github-actions github-actions released this 30 Aug 00:46
v2.7.1
4578a8b

Changed

  • A policy file setting max_tools_per_server: 0 is now rejected, and via
    #202 that terminates startup.
    LimitsPolicy.max_tools_per_server is bounded
    at ge=1; 0 — and any negative value — no longer validates. This can stop
    a gateway that starts today.
    If your policy file (.mcp-gateway-policy.yaml
    / .json, or ~/.claude/gateway-policy.yaml / .json) contains the literal
    line max_tools_per_server: 0, the gateway will refuse to start with
    Invalid policy file ...; remove the line to take the default of 100, or
    set the number of tools you actually want indexed. Such a gateway indexed no
    tools at all before, so the value is unlikely to be in deliberate use — but
    the failure is a hard one and worth searching for. 2.7.0 shipped the log fix
    for this value and deliberately left the bound out: at that time a discovered
    policy that failed validation was discarded in favour of the allow-all
    default, so rejecting one value would have silently discarded the operator's
    entire policy file. #202, in the same release, made that case fatal, which is
    what makes the bound safe to add. ClientManager's max_tools_per_server
    constructor parameter is a separate, programmatic axis and is unchanged — it
    still accepts 0 and still logs that nothing was indexed. (#207)

v2.7.0

Choose a tag to compare

@github-actions github-actions released this 29 Aug 19:26
v2.7.0
4fdae8e

Added

  • The truncation boundary at max_tools_per_server is now pinned by tests at
    limit - 1, limit and limit + 1. Mutating the guard from >= to > —
    which lets a server put one more tool in the catalog than the bound allows —
    previously survived the whole of tests/test_client_manager.py. (#175)

Changed

  • A policy file found by auto-discovery that parses but is not a valid policy
    now terminates startup instead of being discarded.
    This can stop a gateway
    that starts today, and that is the point: the discarded policy was replaced by
    the default GatewayPolicy(), and that default is allow-all — every field
    is a default_factory, so a policy with one mistake in it did not degrade to a
    partial policy, it degraded to no policy. Allow/deny lists, limits and
    redaction all silently reverted to permissive behind a single warning line that
    did not say so. The condition is exact: the file exists, yaml.safe_load /
    json.loads returned without raising, and the result is not a valid
    GatewayPolicy — which includes a list root, a scalar root and an empty YAML
    file, all of which parse cleanly and fail only the object schema. A file the
    parser rejects, or one that cannot be read at all, still warns and continues
    as before: it could be a half-written file, an unrelated .json at the repo
    root, or a merge conflict, and that fallback is deliberate. The surviving
    warning now states plainly that no policy is in effect. Explicit --policy /
    PMCP_POLICY is unchanged — it was already fatal for every mode. (#202)
  • The default policy search paths now follow the working directory. The two
    project-local entries in DEFAULT_POLICY_PATHS were joined with Path.cwd()
    at module import, freezing the directory as of first import; a gateway that
    changed directory before constructing its PolicyManager looked for a policy
    somewhere else, found none, and ran unrestricted — by that road with no warning
    at all, since "no policy file" is legitimately silent. They are resolved at
    construction now. The module attribute remains patchable for tests, and
    absolute entries pass through unchanged. (#202)
  • A downstream tool that declares no inputSchema is now skipped instead of
    indexed.
    This is a behaviour change, and the only one in this set. The
    indexer used to substitute {} for a missing inputSchema — and {} is not
    "we do not know", it is "any arguments at all are valid", published under the
    server's name to every caller and every model reading the catalog. MCP
    requires inputSchema on a tool, so a tool without one is a tool we could not
    read, and such an entry now takes the same route as any other unparseable
    entry: skipped, logged, costing only itself. A listing in which no tool
    parses is treated as a failed listing, so the server's previous tools are kept
    rather than reported removed. An explicitly empty inputSchema: {} is still
    accepted — the server said "any arguments", and that is an answer; only the
    absence, and any non-object value such as null, is unreadable. A server that
    omits inputSchema therefore loses that tool from the catalog where it
    previously appeared with a permissive schema. (#175)

Fixed

  • derive_npm_flags.py --verify no longer reports host-enumerated npm config
    types as table drift.
    The npm flag tables are the node-less fallback for
    npm package identity, and --verify is what keeps them honest against a real
    npm — but it was green on one machine and red on another with identical npm
    and identical source. npm builds local-address's declared type from
    os.networkInterfaces(), so its 51 members here are facts about this
    machine; and when networkInterfaces() throws, npm's getLocalAddresses()
    catches it and returns exactly [null], which the member rule stripped to
    nothing and reported as value: --local-address in table, absent from live npm. A drift check with false positives gets ignored, and an ignored check
    is how real drift ships.

    Detection now happens in the node script, the only place the raw members
    still exist — the serializer maps every string member to '<literal>', so no
    Python-side predicate could tell 51 addresses from loglevel's 8 fixed
    words. It uses npm's own typeDescription === 'IP Address' label (the one
    signal that survives the [null] case) with a net.isIP member scan as an
    independent backstop, and classify() then returns value regardless of the
    members. The flag is not exempted from the comparison: skipping it would
    blind the check to a real arity change on the flag most likely to drift, so
    --verify reports which flags it normalised instead — without printing the
    member count, which is the host fact. The committed tables are unchanged;
    this fixes the comparison, not the data.

    Scope, so the next red --verify is not waved off as another false
    positive:
    this makes the comparison logic host-independent, not the
    tables' freshness. Version skew — tables derived from one npm, checked
    against a newer one — still turns --verify red, correctly and by design.
    That is the signal the check exists to produce. What is gone is only the
    redness that two machines running the same npm could disagree about. A CI
    test now also holds the recorded schema fixture and the committed tables to
    each other, so regenerating one without the other cannot pass silently.
    (#193)

  • _index_tools/_index_resources/_index_prompts no longer overstate the
    catalog.
    _index_resources documented that "the count returned is what was
    actually indexed, not what was offered", while all three returned the length
    of the parsed list. Two entries sharing an identity are two list items and one
    catalog key, so the count was wrong by exactly the number of collisions. The
    count is now of entries that actually landed, and each collision is logged at
    DEBUG naming the id. (#175)

  • adopt_process now clears the server's catalog entries before indexing,
    like every other path into the indexers. Adopting a server previously indexed
    under the same name left the earlier listing's tools in the catalog beside the
    new ones — entries the adopted process does not serve, still routable. (#175)

  • A max_tools_per_server of 0 is no longer reported as a malformed
    listing.
    A zero limit empties the parse result before any entry is examined,
    so reconciliation announced "Every tools entry in the listing was
    unparseable" — blaming the downstream for a decision the gateway's own policy
    file made. Both that message and the parser's truncation warning now name the
    limit. Deliberately not fixed by adding a schema bound: LimitsPolicy still
    accepts 0, because at the time policy auto-discovery swallowed validation
    errors and fell back to an allow-all default, so rejecting the value would
    have silently discarded the operator's entire policy file. #202 — see the
    entry above, which ships in this same release — has since made that case
    fatal, so Field(ge=1) is now safe to add; it is left to a follow-up rather
    than folded in here. (#175)

v2.6.0

Choose a tag to compare

@github-actions github-actions released this 27 Aug 16:49
v2.6.0
3413db4

Added

  • The release-path workflow guards now run in CI. release.yml triggers
    only on tag push — the tag push is the publish — so it never appeared in a
    PR check, and every guard that protected the last change to it was run by
    hand, once. Two new test.yml jobs close that:

    workflows runs scripts/check_workflows.py, which asserts by invariant
    that release.yml's trigger set is exactly push.tags: ["v*"] — no other
    event and no branch or path filter alongside it, since publish holds
    id-token: write against an environment with no protection rules, so any
    extra trigger makes trusted publishing reachable from it — and that no
    workflow file other than release.yml/docker.yml is tag-triggered at all;
    the build → publish → github-release ordering; environment: release by
    name
    ; no if: or continue-on-error at job or step level on any of the
    three jobs (skipping build skips publish through needs, and the tag push
    still concludes green); the
    exact committed permissions: maps at both workflow and job level, the exact
    committed uses: references, and exactly the three expected jobs; that every
    job in every workflow carries a timeout-minutes of 10–30 (a bare
    timeout-minutes: 360 re-creates the six-hour default); and by drift
    that no job present in the PR base has disappeared from any changed workflow,
    including one deleted outright. Drift fails closed: a base ref that does
    not resolve, and a git diff that fails for any other reason (a base sharing
    no history with HEAD exits 128), are failures, never "nothing changed". It
    also runs a digest-pinned actionlint.

    release-diff-ack covers by acknowledgement what an allowlist cannot
    cover by enumeration: any PR touching release.yml fails unless it carries a
    release-change-approved label, read live from the API rather than from the
    event payload frozen at trigger time.

    Not covered, deliberately: a timeout above the 10-minute floor but below a
    job's real p100; environment protection rules, which live in GitHub settings
    and are invisible to any file check; and if:-skipping the guard job itself,
    which GitHub counts as satisfying a required check. Per-mutant exit codes,
    including the ones that stay green, are in
    .consiliency/evidence/mutation-189.md.

    Because the uses: and permissions: allowlists are exact, a legitimate
    edit to release.yml — bumping an action, granting a scope — must update the
    constants in scripts/check_workflows.py in the same PR. That is intended.

Fixed

  • npm package identity now comes from npm's own parser where it is certain, and
    is refused otherwise.
    The hand-written npm flag tables have been repaired five
    times (#180 → #192 → #194 → #195 → the 2.5.2 nullable-boolean
    spelling), and every defect was in the rules around the tables rather than a
    missing entry — so every repair produced a confident wrong answer, which the
    freshness gate reads as positive confirmation that a cached tool description
    still describes the configured package.

    For an npx/npm server the gateway now asks the host npm's own nopt, its
    own @npmcli/config definitions and its own npm-package-arg, through a
    faithful port of npm's npx-cli.js pre-scan, and accepts the answer only when
    nothing in the invocation could redirect resolution. It refuses when:

    • the parsed configuration contains any key beyond --yes and --package
      (--registry, --userconfig, --prefix, --cache, --call, --workspace,
      a shorthand such as --silent that expands to --loglevel, or an unknown
      flag) — this is an allowlist of plain shapes, not a denylist of dangerous
      ones;
    • the server's environment overlay, or the gateway's own process
      environment, sets npm_config_* (case-insensitive), PATH, HOME,
      NODE_PATH, NODE_OPTIONS, PREFIX or NVM_*;
    • walking up from the effective working directory, npm would set a local
      prefix (a package.json or node_modules in any ancestor), because a
      project .npmrc can rename the package and a local node_modules/.bin entry
      means npm never reaches the registry at all;
    • npm-package-arg reports anything but a registry spec — notably an alias
      (npx -y myalias@npm:left-pad really runs left-pad, and the alias name is a
      squattable different package);
    • the npm subcommand has no package operand (npm run, npm start, npm test,
      npm create, a typo, bare npm -y pkg, and now npm dlx, which is
      pnpm/yarn spelling and is not an npm command at all);
    • the spawn-time self-test against the host's own parser fails, bin/npx-cli.js
      is not one this port was verified against, or npm's parser cannot be loaded.
      A failed self-test refuses — it does not fall back to the tables, because
      a failed self-test is precisely the evidence that the tables' model of npm is
      wrong. One WARNING is logged.

    Refusing costs auto-update coverage for an unusual configuration: the server
    keeps running, but its package is reported as unknown, so its descriptions
    refresh every cycle and gateway.update_server cannot name a package for it.
    Measured cost on the shipped manifest: zero — all 79 npm-family servers use
    the plain npx -y <pkg> shape and all 79 resolve to the same package the real
    npx binary fetches.

    Where node is not installed the flag tables remain in use unchanged, which is
    the behaviour every release through 2.5.1 shipped.

    Known residual: a package= or registry= line in a user or global
    .npmrc changes what npm resolves and the gateway cannot see it. Project-level
    .npmrc is covered by the local-prefix refusal, and npm_config_* in the
    gateway's own environment is covered by the process-environment check; the
    user/global rc file is the one input that remains unguarded.

    detect_package_type, _npm_package_arg, get_package_version and
    gateway.update_server's pin detection all take the server's environment
    overlay and working directory as required parameters now, since both are
    identity inputs.

v2.5.2

Choose a tag to compare

@github-actions github-actions released this 26 Aug 23:06
v2.5.2
0edaaf4

Fixed

  • Six npm flag spellings read a literal null as the package name. 2.5.1
    added a table of the boolean flags that take a literal null as their value
    rather than as the package name — null is a real published npm package, so
    the distinction decides a server's identity, and refresher.py's freshness
    gate treats a matching identity as positive confirmation that a cached
    tool description still describes the configured package. That table was
    written by hand with 12 entries. npm has five nullable boolean definitions
    (yes, optional, production, workspaces, expect-results) but
    eighteen spellings for them, because y, ws, n and no are
    shorthands — n and no both expand to --no-yes — and each is legal in
    both its -x and --x form. The six that were missing are --y, -ws,
    -n, --n, -no, --no
    : under 2.5.1 each of these read the following
    null as the package name, so npm exec -n null server-a and
    npm exec -n null server-b both resolved to the package null and could be
    served each other's cached tool descriptions.

    Not a regression between releases — 2.4.1, 2.5.0 and 2.5.1 all resolve
    these six to null; verified by running each released version's
    detect_package_type directly. The blanket rule that briefly handled them
    correctly existed only on main between two unreleased commits, so no shipped
    version was ever right about them. 2.5.2 is the first.

  • The set is now generated, not hand-listed.
    .consiliency/notes/derive_npm_flags.py derives it from npm's own
    @npmcli/config definitions: a spelling is nullable iff its resolution
    target, after shorthand expansion and after stripping a leading no-, is a
    definition whose declared type includes null. It was the fourth defect in
    this parser traceable to hand-transcribing npm's behaviour.

  • --verify now covers this table, which previously had no drift
    protection at all. Each definition is probed with a literal null, and every
    one of npm's 442 enumerable flag spellings is run through npm's own parser as
    npm exec <flag> null zz — a definition-level check alone would not have
    caught a spelling omission. The new check rejects the shipped 2.5.1 table
    with exactly six mismatches.

    This does not close the broader gap tracked in
    #195: attached values (--global=pkg), npx's own -p=
    rewriting, and npx's -n removal are still unhandled.

v2.5.1

Choose a tag to compare

@github-actions github-actions released this 26 Aug 21:27
v2.5.1
ea8333c

Fixed

  • npm no longer reads a flag's value as the package name. npm was the last
    of the five ecosystems still failing open on an unrecognised flag: the scan
    skipped anything starting - and took the next bare token, so
    npm exec --loglevel silly server-a and … server-b both resolved to
    silly. Because 2.4.0's identity gate treats a matching name as a positive
    confirmation
    , two unrelated servers collapsed into one identity and one was
    served the other's cached tool descriptions. --registry, --global false
    and --color always collided the same way.

    npm's flag arity is now generated from npm's own config schema
    (@npmcli/config's definitions and shorthands, 181 flags and 40
    shorthands) rather than transcribed by hand, and an unlisted flag makes the
    scan report no identity instead of guessing. Shorthands are expanded from
    npm's own map, so npm --silent exec <pkg> keeps resolving and the previous
    hand-coded -y special case is retired. Two flags whose arity depends on the
    next token's content — --color and --browser — are deliberately
    unlisted and therefore refused.

    The cost, deliberately: a server launched with a flag npm's own schema
    does not describe can no longer be auto-updated. gateway.update_server
    refuses it by name and command line rather than probing. An omission costs
    auto-update for one unusual config — visible, and fixable by adding the flag
    — where the previous "take the next token" default produced a silent
    collision instead. No bundled manifest server is affected: all 98 launchable
    entries resolve exactly as before.

    Two places where the boolean rule is subtler than "a switch takes no value":

    • null is a real published npm package. npm's parser consumes a literal
      null after only the five nullable booleans (--yes, --optional,
      --production, --workspaces, --expect-results); after any other
      boolean, null is the package. The rule is scoped to those five, so
      npm exec --global null pkg-a and … pkg-b stay distinct.
    • npx behaves the opposite way to npm. It pre-scans its arguments and
      inserts -- before the first positional, so a boolean switch there consumes
      nothing — verified against the real binary: npx --global true pkg runs the
      package true. Because the --no- family differs again, npx reports no
      identity for these forms rather than a modelled guess.

    Known remaining gap, tracked in
    #195: the same class
    survives in rarer spellings — an attached value (--global=pkg), nopt's
    abbreviation matching (-n, --y), and npx's own rewriting of -p= and
    removal of -n. No server in the bundled manifest uses any of them.

v2.5.0

Choose a tag to compare

@github-actions github-actions released this 26 Aug 11:03
v2.5.0
a7bb061

Fixed

  • A command-line flag's value is no longer mistaken for the package name.
    Package detection skipped flags but not the tokens those flags carry, so the
    first "non-flag" argument was routinely a flag's argument. uvx --python 3.12 pkg-a and uvx --python 3.12 pkg-b both resolved to 3.12, and because
    2.4.0's identity gate compares exactly this name to decide whether a cached
    description still describes the configured package, an equal name read as a
    positive confirmation — serving one package's tool descriptions for a
    different package indefinitely. Seven forms were affected: uvx --python and
    --with, pip --index-url, cargo --features, docker --env-file and
    --mount, and npm exec --package=<pkg> -- <bin> (which returned the
    binary, so two packages exposing the same binary confirmed as one).

    Flags are now classified per ecosystem as value-taking, boolean, or
    positive (uvx --from, cargo -p, npm --package — where the value is
    the package), with tables transcribed from each tool's own --help.

    Three user-visible consequences:

    1. Affected servers refresh once. Their cached identity changes, the same
      one-time migration 2.4.0 and 2.4.1 made.
    2. A server launched with a flag pmcp does not recognise can no longer be
      auto-updated.
      This is the cost, and it is deliberate: anything unlisted
      now reports no identity rather than guessing. An omission costs
      auto-update for one unusual config — visible, and fixable by adding the
      flag — where the previous "take the next token" default produced a silent
      collision instead. gateway.update_server refuses such a server by name
      and command line rather than probing.
    3. The identity gate now actually holds for the forms #180 left open.
  • uvx --from values are read as PEP 508 requirements. browser-use[cli]
    resolves to browser-use and index-it-mcp==1.2.0 to index-it-mcp. This
    repairs a live defect: the bundled manifest ships --from browser-use[cli],
    and a PyPI lookup for that literal string returns nothing, so that entry's
    version checks had been silently failing. A git+https://… value keeps the
    whole URL as its identity — distinct URLs are distinct packages.

  • gateway.update_server no longer misreads a uvx version pin. Pin
    detection now shares one scan with package detection instead of skipping
    every --prefixed token and reading == off the first bare one. That was
    wrong in both directions: --from=pkg==1.2.0 reported no pin, so an
    explicitly pinned server could have been moved to the latest version, while
    --with requests==2.0 pkg reported an injected dependency's version as the
    server's own pin and refused an update that was never pinned.

    The README's documented pin form — uvx --python 3.12 --from index-it-mcp==1.2.0 index-it-mcp — previously identified the package as
    3.12; it now resolves correctly and needs no configuration change.

v2.4.1

Choose a tag to compare

@github-actions github-actions released this 26 Aug 03:26
v2.4.1
4f81744

Security

  • gateway.update_server no longer installs and executes a registry package
    derived from a misparsed command.
    The update probe is built from the parsed
    package name — npx -y {name}@latest --help — and npx -y installs without
    prompting. A server configured as npm run mcp names a script in the
    local package.json, not a registry package, but the parser returned it as
    one, so pmcp fetched and ran whatever occupied that name on the public
    registry. Short generic script names (run, start, dev, mcp) are
    exactly the kind that can be registered and waited on.

    npm package detection is now an allowlist: only exec, x, install,
    i, add and dlx put a registry package in the next position. Every other
    subcommand — run, start, test, stop, restart, run-script, init,
    create — and every misspelling of one reports no recoverable package
    identity
    , and update_server refuses on that before constructing any
    probe. That is the same rule the identity gate follows: cannot confirm, so do
    not act on a guess.

    An allowlist rather than a denylist of script runners, because the
    consequence of being wrong is asymmetric: failing closed costs only the
    ability to auto-update a server launched by an unusual form, while failing
    open costs arbitrary package execution. (npm create foo also shows why
    synthesising a name is not safe: npm resolves it to the package create-foo,
    so foo names a different package than the one npm would run.)

    Reaching this required a server configured with an affected form and an
    operator invoking gateway.update_server on it; it was not remotely
    triggerable. npx -y run still resolves normally — the refusal is scoped to
    npm subcommands whose operand is not a package, not to those names.

Fixed

  • Two different packages no longer share one identity for common docker and
    npm command forms.
    2.4.0's identity gate decides whether a cached
    description still describes the configured package by comparing the name
    detect_package_type returns, so a name that was stable across two different
    packages was read as a positive confirmation — and the freshness
    short-circuit went on serving the wrong package's tool descriptions.

    Two independent causes, both closed:

    • Docker references split on the first :, so registry:5000/old-image
      and registry:5000/new-image both resolved to the image registry — the
      registry host, not an image at all. A colon only introduces a tag when it
      appears in the final path segment; before the last / it is a registry
      host:port. The correct rule already existed in this module as
      _docker_image_tag, so the fix adds its paired complement rather than a
      second, divergent implementation of the same rule.
    • npm subcommands were taken as the package name, so npm exec old-pkg
      and npm exec new-pkg both resolved to exec. A leading subcommand
      (exec, x, run, install, i, add, create, dlx) is now skipped
      — once, and only for npm, so npm install i still finds the real package
      i and npx -y exec still finds a package genuinely named exec.

    Affected servers refresh once. A docker server on a host:port registry
    or an npm exec server now has a different package identity than the one
    its cache entry recorded, so that entry fails the identity check once and is
    regenerated — the same one-time migration 2.4.0's package_type addition
    caused.

  • A docker digest is now recognised as the pin it is. gateway.update_server
    read the tag from the whole reference, so img@sha256:abc reported a pin of
    abc — a fragment of the digest presented as a version — and
    img:1.2@sha256:abc reported 1.2@sha256:abc instead of a usable value. A
    digest is the tightest pin docker offers, so it is now reported whole and
    checked before the tag: img@sha256:…, img:1.2@sha256:… and
    img:latest@sha256:… all report the digest. That last form matters — a
    latest tag must not discard a real digest pin. Previously such a server
    could be "updated": pmcp would pull image:latest, restart the unchanged
    digest-pinned configuration, and record the registry's newest digest while
    still running the old immutable image.

  • A version pin on an npm exec server is now detected. Pin detection
    shares its argument scan with package detection, so it inherited the
    subcommand bug: npm exec pkg@1.2 scanned to exec, which carries no
    version suffix, and a real pin was reported as unpinned.

    This narrows #180 rather than closing it. Package identity
    is still collapsed wherever a flag's value is taken as the package name —
    docker run --env-file X <image>, docker run --mount <spec> <image>,
    npm exec --package=<pkg>, and the uvx/pip/cargo equivalents. Those are
    tracked on #182, and #183 tracks a related but
    more serious consequence of a misparse.

v2.4.0

Choose a tag to compare

@github-actions github-actions released this 25 Aug 08:46
v2.4.0
f5ea5ec

Fixed

  • A cached description is now checked against the package that is actually
    configured, not just against its version.
    All three refresh sites —
    refresh_server's up-to-date short-circuit, refresh_all, and
    check_staleness — paired a cached entry with a server config by name and
    then decided freshness by comparing versions alone. Nothing asked whether
    the cache still described the same package, so swapping the configured
    package at an equal version served the wrong package's tool descriptions
    indefinitely: a cache for old-pkg@1.0.0 against a config for new-pkg@1.0.0
    looked current forever. The docker case was already covered — a version
    against a digest is incomparable, which is not not_newer — but a
    same-ecosystem swap and an npm ↔ pypi ↔ cargo swap were not.

    Identity is resolved before the comparison at all three sites now. An
    unknown side means "cannot confirm identity", and that resolves to
    refresh — never to "cannot compare, so skip the check."
    The second phrasing
    is the natural one to reach for and is the same fail-open collapse as
    not is_version_newer(...), which shipped three times
    (#155, #156, #163) before it was made unrepresentable.

    The cached entry gained a package_type, because package is a bare name
    carrying no ecosystem and npm, pypi and cargo all produce orderable release
    versions — so npm foo@1.0.0 and pypi foo@1.0.0 were indistinguishable to
    a name comparison. A cache written before this release has no type, reads as
    unknown, and refreshes once. Nothing has to be migrated by hand and the cache
    format needs no version bump.

    One cosmetic consequence: a stale report is a (cached_version, latest_version) pair, so an entry that is stale by identity at an equal
    version prints srv: 1.0.0 -> 1.0.0. Confusing to read, but not wrong — that
    entry genuinely does need regenerating.

  • pmcp refresh --check-versions now honours --cache-dir. run_refresh
    computed the cache path from --cache-dir and then called check_staleness()
    with no arguments, dropping it, so the check silently inspected the default
    cache instead of the one that was asked for. Anyone pointing
    --check-versions at a non-default cache was reading a different file than
    they named, with nothing in the output to say so.

Changed

  • pmcp refresh --check-versions now reports a server whose package it cannot
    look up separately, as unconfirmed rather than as stale.
    A server launched
    as node /opt/srv.js or python -m thing has no classifiable package, so its
    configured identity is unknown, and "cannot confirm identity" resolves to
    refresh. That is the right rule — the only alternative is to read "cannot
    classify" as "assume it matches", which is the fail-open reading that let a
    swapped package look current in the first place — but it would have made such
    a server appear under "servers with newer versions" on every run, permanently,
    beneath a Run 'pmcp refresh --force' to update. footer that could not settle
    it, since the next check still cannot classify the package. So the report is
    now split. Servers with a genuinely newer version keep the existing output and
    that footer; servers whose current version could not be looked up are listed
    under their own heading which says so, notes that this is not the same as
    being out of date, and points out that their descriptions are regenerated by
    the next plain pmcp refresh. Previously these servers were skipped in
    silence. A manifest of only registry-installed (npm/pypi/cargo/docker) servers
    is unaffected.
  • refresh_all now drops an unclassifiable server's cached descriptions when
    regeneration fails, where it previously kept them.
    A server that fails the
    identity gate has its cached entry discarded up front and is regenerated; if
    that regeneration then fails — the server does not start, a version lookup
    times out — neither the failure fallback nor the final merge puts the old
    entry back, and the server is left with no cached descriptions until a later
    refresh succeeds. For a node/python server this costs something real:
    there was never evidence of a package mismatch, only an inability to confirm
    one, so a transient startup failure now costs descriptions that were probably
    still accurate. Writing back descriptions that may describe a different
    package is the outcome this release exists to prevent, so the trade is
    deliberate — but it is a trade, not a free win.