Repository navigation
Releases: Consiliency/pmcp
Release list
v2.7.3
Changed
- PMCP no longer implies it verified where a server-supplied auth URL
points. It never did:_is_public_auth_hostclassifies IP literals and
accepts a DNS name without resolving it, so
https://metadata.google.internal/...in an elicitation payload or a
WWW-Authenticateheader 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_verifiedreport whether PMCP itself
classified the host as a public literal, and all three default to
unverified. The qualification is threaded into thenext_stepstring an
agent actually follows, and intopmcp auth connect/pmcp auth acknowledge
output in both human text and--json.pmcp doctorno 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_urlnow splits by provenance: a URL parsed out of a
server's error payload loses loopback HTTP, while one the operator types into
gateway.auth_connectkeeps 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_metadatanow 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 reachedurlopen(#211).
v2.7.2
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, andresource_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
carrying127.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 reads2852039166and0xA9FEA9FEas
169.254.169.254and0177.0.0.1as127.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, or02-00-5E-FEwith the u/g bit set),
not on the5efehextet alone, so an ordinary global address such as
2606:4700::1234:5efe:a00:5is 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 POSIXinet_atonalso rejects as an address. The
DNS-name limitation is now stated in the docstring,README.mdand
SECURITY.mdinstead of being implied away. The error message no longer claims
the host "must be public", since only IP literals are ever checked. (#210) - The classifier subtracted a list of bad properties (
v2.7.1
Changed
- A policy file setting
max_tools_per_server: 0is now rejected, and via
#202 that terminates startup.LimitsPolicy.max_tools_per_serveris bounded
atge=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
linemax_tools_per_server: 0, the gateway will refuse to start with
Invalid policy file ...; remove the line to take the default of100, 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'smax_tools_per_server
constructor parameter is a separate, programmatic axis and is unchanged — it
still accepts0and still logs that nothing was indexed. (#207)
v2.7.0
Added
- The truncation boundary at
max_tools_per_serveris now pinned by tests at
limit - 1,limitandlimit + 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 oftests/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 defaultGatewayPolicy(), and that default is allow-all — every field
is adefault_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.loadsreturned 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.jsonat 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_POLICYis unchanged — it was already fatal for every mode. (#202) - The default policy search paths now follow the working directory. The two
project-local entries inDEFAULT_POLICY_PATHSwere joined withPath.cwd()
at module import, freezing the directory as of first import; a gateway that
changed directory before constructing itsPolicyManagerlooked 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
inputSchemais now skipped instead of
indexed. This is a behaviour change, and the only one in this set. The
indexer used to substitute{}for a missinginputSchema— 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
requiresinputSchemaon 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 emptyinputSchema: {}is still
accepted — the server said "any arguments", and that is an answer; only the
absence, and any non-object value such asnull, is unreadable. A server that
omitsinputSchematherefore loses that tool from the catalog where it
previously appeared with a permissive schema. (#175)
Fixed
-
derive_npm_flags.py --verifyno longer reports host-enumerated npm config
types as table drift. The npm flag tables are the node-less fallback for
npm package identity, and--verifyis 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 buildslocal-address's declaredtypefrom
os.networkInterfaces(), so its 51 members here are facts about this
machine; and whennetworkInterfaces()throws, npm'sgetLocalAddresses()
catches it and returns exactly[null], which the member rule stripped to
nothing and reported asvalue: --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 fromloglevel's 8 fixed
words. It uses npm's owntypeDescription === 'IP Address'label (the one
signal that survives the[null]case) with anet.isIPmember scan as an
independent backstop, andclassify()then returnsvalueregardless 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
--verifyreports 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
--verifyis 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--verifyred, 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_promptsno longer overstate the
catalog._index_resourcesdocumented 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_processnow 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_serverof0is 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:LimitsPolicystill
accepts0, 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, soField(ge=1)is now safe to add; it is left to a follow-up rather
than folded in here. (#175)
v2.6.0
Added
-
The release-path workflow guards now run in CI.
release.ymltriggers
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 newtest.ymljobs close that:workflowsrunsscripts/check_workflows.py, which asserts by invariant
thatrelease.yml's trigger set is exactlypush.tags: ["v*"]— no other
event and no branch or path filter alongside it, sincepublishholds
id-token: writeagainst an environment with no protection rules, so any
extra trigger makes trusted publishing reachable from it — and that no
workflow file other thanrelease.yml/docker.ymlis tag-triggered at all;
thebuild → publish → github-releaseordering;environment: releaseby
name; noif:orcontinue-on-errorat job or step level on any of the
three jobs (skippingbuildskipspublishthroughneeds, and the tag push
still concludes green); the
exact committedpermissions:maps at both workflow and job level, the exact
committeduses:references, and exactly the three expected jobs; that every
job in every workflow carries atimeout-minutesof 10–30 (a bare
timeout-minutes: 360re-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 agit diffthat 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-ackcovers by acknowledgement what an allowlist cannot
cover by enumeration: any PR touchingrelease.ymlfails unless it carries a
release-change-approvedlabel, 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; andif:-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:andpermissions:allowlists are exact, a legitimate
edit torelease.yml— bumping an action, granting a scope — must update the
constants inscripts/check_workflows.pyin 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/npmserver the gateway now asks the host npm's ownnopt, its
own@npmcli/configdefinitions and its ownnpm-package-arg, through a
faithful port of npm'snpx-cli.jspre-scan, and accepts the answer only when
nothing in the invocation could redirect resolution. It refuses when:- the parsed configuration contains any key beyond
--yesand--package
(--registry,--userconfig,--prefix,--cache,--call,--workspace,
a shorthand such as--silentthat 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, setsnpm_config_*(case-insensitive),PATH,HOME,
NODE_PATH,NODE_OPTIONS,PREFIXorNVM_*; - walking up from the effective working directory, npm would set a local
prefix (apackage.jsonornode_modulesin any ancestor), because a
project.npmrccan rename the package and a localnode_modules/.binentry
means npm never reaches the registry at all; npm-package-argreports anything but a registry spec — notably an alias
(npx -y myalias@npm:left-padreally runsleft-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, barenpm -y pkg, and nownpm 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 asunknown, so its descriptions
refresh every cycle andgateway.update_servercannot name a package for it.
Measured cost on the shipped manifest: zero — all 79 npm-family servers use
the plainnpx -y <pkg>shape and all 79 resolve to the same package the real
npxbinary 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=orregistry=line in a user or global
.npmrcchanges what npm resolves and the gateway cannot see it. Project-level
.npmrcis covered by the local-prefix refusal, andnpm_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_versionand
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. - the parsed configuration contains any key beyond
v2.5.2
Fixed
-
Six npm flag spellings read a literal
nullas the package name. 2.5.1
added a table of the boolean flags that take a literalnullas their value
rather than as the package name —nullis a real published npm package, so
the distinction decides a server's identity, andrefresher.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, becausey,ws,nandnoare
shorthands —nandnoboth expand to--no-yes— and each is legal in
both its-xand--xform. The six that were missing are--y,-ws,
-n,--n,-no,--no: under 2.5.1 each of these read the following
nullas the package name, sonpm exec -n null server-aand
npm exec -n null server-bboth resolved to the packagenulland 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 tonull; verified by running each released version's
detect_package_typedirectly. The blanket rule that briefly handled them
correctly existed only onmainbetween 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.pyderives it from npm's own
@npmcli/configdefinitions: a spelling is nullable iff its resolution
target, after shorthand expansion and after stripping a leadingno-, is a
definition whose declared type includesnull. It was the fourth defect in
this parser traceable to hand-transcribing npm's behaviour. -
--verifynow covers this table, which previously had no drift
protection at all. Each definition is probed with a literalnull, 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-nremoval are still unhandled.
v2.5.1
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-aand… server-bboth 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 alwayscollided the same way.npm's flag arity is now generated from npm's own config schema
(@npmcli/config'sdefinitionsandshorthands, 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, sonpm --silent exec <pkg>keeps resolving and the previous
hand-coded-yspecial case is retired. Two flags whose arity depends on the
next token's content —--colorand--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":
nullis a real published npm package. npm's parser consumes a literal
nullafter only the five nullable booleans (--yes,--optional,
--production,--workspaces,--expect-results); after any other
boolean,nullis the package. The rule is scoped to those five, so
npm exec --global null pkg-aand… pkg-bstay distinct.npxbehaves the opposite way tonpm. 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 pkgruns the
packagetrue. Because the--no-family differs again,npxreports 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), andnpx's own rewriting of-p=and
removal of-n. No server in the bundled manifest uses any of them.
v2.5.0
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-aanduvx --python 3.12 pkg-bboth resolved to3.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--pythonand
--with, pip--index-url, cargo--features, docker--env-fileand
--mount, andnpm 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:
- Affected servers refresh once. Their cached identity changes, the same
one-time migration 2.4.0 and 2.4.1 made. - 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_serverrefuses such a server by name
and command line rather than probing. - The identity gate now actually holds for the forms #180 left open.
- Affected servers refresh once. Their cached identity changes, the same
-
uvx --fromvalues are read as PEP 508 requirements.browser-use[cli]
resolves tobrowser-useandindex-it-mcp==1.2.0toindex-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. Agit+https://…value keeps the
whole URL as its identity — distinct URLs are distinct packages. -
gateway.update_serverno 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.0reported no pin, so an
explicitly pinned server could have been moved to the latest version, while
--with requests==2.0 pkgreported 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
Security
-
gateway.update_serverno 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— andnpx -yinstalls without
prompting. A server configured asnpm run mcpnames a script in the
localpackage.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,addanddlxput 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, andupdate_serverrefuses 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 fooalso shows why
synthesising a name is not safe: npm resolves it to the packagecreate-foo,
sofoonames a different package than the one npm would run.)Reaching this required a server configured with an affected form and an
operator invokinggateway.update_serveron it; it was not remotely
triggerable.npx -y runstill 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
dockerand
npmcommand forms. 2.4.0's identity gate decides whether a cached
description still describes the configured package by comparing the name
detect_package_typereturns, 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
:, soregistry:5000/old-image
andregistry:5000/new-imageboth resolved to the imageregistry— 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. npmsubcommands were taken as the package name, sonpm exec old-pkg
andnpm exec new-pkgboth resolved toexec. A leading subcommand
(exec,x,run,install,i,add,create,dlx) is now skipped
— once, and only fornpm, sonpm install istill finds the real package
iandnpx -y execstill finds a package genuinely namedexec.
Affected servers refresh once. A docker server on a
host:portregistry
or annpm execserver 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'spackage_typeaddition
caused. - Docker references split on the first
-
A docker digest is now recognised as the pin it is.
gateway.update_server
read the tag from the whole reference, soimg@sha256:abcreported a pin of
abc— a fragment of the digest presented as a version — and
img:1.2@sha256:abcreported1.2@sha256:abcinstead 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
latesttag must not discard a real digest pin. Previously such a server
could be "updated": pmcp would pullimage: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 execserver is now detected. Pin detection
shares its argument scan with package detection, so it inherited the
subcommand bug:npm exec pkg@1.2scanned toexec, 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 theuvx/pip/cargoequivalents. Those are
tracked on #182, and #183 tracks a related but
more serious consequence of a misparse.
v2.4.0
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 forold-pkg@1.0.0against a config fornew-pkg@1.0.0
looked current forever. The docker case was already covered — a version
against a digest isincomparable, which is notnot_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, becausepackageis a bare name
carrying no ecosystem and npm, pypi and cargo all produce orderable release
versions — so npmfoo@1.0.0and pypifoo@1.0.0were 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 printssrv: 1.0.0 -> 1.0.0. Confusing to read, but not wrong — that
entry genuinely does need regenerating. -
pmcp refresh --check-versionsnow honours--cache-dir.run_refresh
computed the cache path from--cache-dirand then calledcheck_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-versionsat a non-default cache was reading a different file than
they named, with nothing in the output to say so.
Changed
pmcp refresh --check-versionsnow reports a server whose package it cannot
look up separately, as unconfirmed rather than as stale. A server launched
asnode /opt/srv.jsorpython -m thinghas 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 aRun '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 plainpmcp refresh. Previously these servers were skipped in
silence. A manifest of only registry-installed (npm/pypi/cargo/docker) servers
is unaffected.refresh_allnow 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 anode/pythonserver 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.