Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 198 additions & 38 deletions .consiliency/notes/derive_npm_flags.py

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.5.2] - 2026-08-26

### 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
Consiliency/pmcp#195: attached values (`--global=pkg`), npx's own `-p=`
rewriting, and npx's `-n` removal are still unhandled.

## [2.5.1] - 2026-08-26

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pmcp"
version = "2.5.1"
version = "2.5.2"
description = "PMCP - Progressive MCP: Minimal context bloat with on-demand tool discovery"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/pmcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""PMCP - A meta-server for minimal Claude Code tool bloat."""

__version__ = "2.5.1"
__version__ = "2.5.2"
31 changes: 25 additions & 6 deletions src/pmcp/manifest/version_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,15 +761,30 @@ def _npm_tag(package: str) -> str | None:
# `--yes null B` both resolve to the package `null` (ah board review).
_NPM_BOOLEAN_LITERALS = frozenset({"true", "false"})

# Booleans whose declared type is `null|Boolean` -- these, and ONLY these, also
# consume a literal `null`. Verified against nopt: `npm exec --yes null zz`
# leaves `zz`, but `npm exec --global null zz` leaves `null zz`, so `null` is
# the package for every NON-nullable boolean. A blanket `null` rule therefore
# minted a wrong identity across the other ~180 boolean entries -- and `null`
# is a real published npm package (ah board review, correctness seat).
# GENERATED by .consiliency/notes/derive_npm_flags.py -- do not hand-edit.
#
# The boolean spellings whose declared type is `null|Boolean` -- these, and
# ONLY these, also consume a literal `null`. Verified against nopt: `npm exec
# --yes null zz` leaves `zz`, but `npm exec --global null zz` leaves `null zz`,
# so `null` is the package for every NON-nullable boolean. A blanket `null`
# rule therefore minted a wrong identity across the other ~180 boolean entries
# -- and `null` is a real published npm package (ah board review, correctness
# seat).
#
# Hand-listing it was the other way to get this wrong, and 2.5.1 did: the five
# nullable definitions have EIGHTEEN spellings, because `y`, `ws`, `n` and `no`
# are shorthands (`n` and `no` both expand to `--no-yes`) and each is emitted
# in both its `-x` and `--x` form. The hand-written table carried twelve, so
# `--y -ws -n --n -no --no` each minted the package `null`. The set is now
# derived -- 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` -- and `derive_npm_flags.py --verify` re-probes every
# spelling against nopt, so a future npm cannot change it silently.
_NPM_NULLABLE_BOOLEAN_FLAGS = frozenset(
{
"--expect-results",
"--n",
"--no",
"--no-expect-results",
"--no-optional",
"--no-production",
Expand All @@ -779,7 +794,11 @@ def _npm_tag(package: str) -> str | None:
"--production",
"--workspaces",
"--ws",
"--y",
"--yes",
"-n",
"-no",
"-ws",
"-y",
}
)
Expand Down
138 changes: 129 additions & 9 deletions tests/test_version_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import pytest

from pmcp.manifest import version_checker
from pmcp.manifest.version_checker import (
_digest_identity,
_docker_image_name,
Expand Down Expand Up @@ -2352,6 +2353,13 @@ def test_real_boolean_still_consumes_the_literal(self) -> None:
"pkg",
)

def test_baked_value_shorthand_still_skips_an_ordinary_token(self) -> None:
"""Not consuming a literal must not become not skipping at all."""
assert detect_package_type("npm", ["--silent", "exec", "pkg"]) == (
"npm",
"pkg",
)


class TestNpxBooleanLiterals:
"""npx parses boolean switches differently from npm, so pmcp refuses.
Expand Down Expand Up @@ -2379,12 +2387,21 @@ def test_npx_boolean_without_a_literal_still_resolves(self) -> None:


class TestOnlyNullableBooleansConsumeNull:
"""`null` is a real published npm package, and only 5 flags consume it.
"""`null` is a real published npm package: 5 definitions, 18 spellings.

Verified against nopt: `npm exec --yes null zz` leaves `zz` (nullable
consumes), while `npm exec --global null zz` leaves `null zz` -- so `null`
is the package. A blanket rule applying `null` to all ~198 boolean entries
minted a wrong identity for the other ~193 (ah board review).
is the package. A blanket rule applying `null` to all 198 boolean entries
minted a wrong identity for the other 180 (ah board review).

The opposite error shipped in 2.5.1: the table was hand-written with 12 of
the 18 spellings, so `--y -ws -n --n -no --no` each read the `null` as the
package. The 5 nullable definitions (`yes`, `optional`, `production`,
`workspaces`, `expect-results`) reach 18 spellings because `y`, `ws`, `n`
and `no` are shorthands -- `n` and `no` BOTH expand to `--no-yes` -- and
every short alias/shorthand key is legal in both `-x` and `--x` form.
The table is generated now; these pins are what keeps a regeneration
honest on a host without npm.
"""

def test_nullable_boolean_consumes_null(self) -> None:
Expand All @@ -2393,20 +2410,123 @@ def test_nullable_boolean_consumes_null(self) -> None:
"zz",
)

@pytest.mark.parametrize(
"flag",
[
"-y",
"--y",
"--yes",
"-n",
"--n",
"-no",
"--no",
"-ws",
"--ws",
"--workspaces",
"--no-yes",
"--optional",
"--no-optional",
"--production",
"--no-production",
"--expect-results",
"--no-expect-results",
"--no-workspaces",
],
)
def test_every_nullable_spelling_consumes_null(self, flag: str) -> None:
"""All 18, one per spelling. 2.5.1 failed six of these.

Cross-checked against nopt with npm's own definitions and shorthands:
`nopt(types, shorthands, ["exec", flag, "null", "zz"]).argv.remain` is
`["exec", "zz"]` for every flag here, i.e. the `null` is the flag's
value and `zz` is the package.
"""
assert detect_package_type("npm", ["exec", flag, "null", "zz"]) == (
"npm",
"zz",
)

def test_non_nullable_boolean_does_not_consume_null(self) -> None:
assert detect_package_type("npm", ["exec", "--global", "null", "zz"]) == (
"npm",
"null",
)

def test_true_and_false_are_consumed_by_any_boolean(self) -> None:
assert detect_package_type("npm", ["exec", "--global", "false", "a"]) == (
@pytest.mark.parametrize("flag", ["-g", "--global", "-f", "--force", "--no-global"])
def test_non_nullable_spellings_leave_null_as_the_package(self, flag: str) -> None:
"""The other direction: over-broad is just as wrong as too narrow.

nopt leaves `["exec", "null", "zz"]` for each of these, so the package
really is `null` and consuming it would collapse two configs.
"""
assert detect_package_type("npm", ["exec", flag, "null", "zz"]) == (
"npm",
"a",
"null",
)

def test_baked_value_shorthand_still_skips_an_ordinary_token(self) -> None:
assert detect_package_type("npm", ["--silent", "exec", "pkg"]) == (
def test_nullable_spellings_do_not_collapse_two_configs(self) -> None:
"""The identity consequence, stated directly.

Under 2.5.1 both of these resolved to `null`, so the freshness gate
would confirm one server's cached tools against the other's config.
"""
got_a = detect_package_type("npm", ["exec", "-n", "null", "server-a"])
got_b = detect_package_type("npm", ["exec", "-n", "null", "server-b"])
assert got_a == ("npm", "server-a")
assert got_b == ("npm", "server-b")
assert got_a != got_b

def test_nullable_table_is_a_subset_of_the_boolean_table(self) -> None:
"""A nullable entry outside the boolean table is unreachable.

`_npm_package_arg` only consults the nullable table from inside the
boolean branch, so an entry missing from `_NPM_BOOLEAN_FLAGS` would be
dead. `derive_npm_flags.py --verify` pins the same property against a
live npm; this pins it without one.
"""
assert (
version_checker._NPM_NULLABLE_BOOLEAN_FLAGS
<= version_checker._NPM_BOOLEAN_FLAGS
)

def test_nullable_table_is_pinned_to_the_exact_derived_set(self) -> None:
"""The table is pinned by VALUE, not just by shape.

Every other check here is one-directional: the parametrized spellings
prove each of the 18 consumes `null`, and the subset check proves none
is unreachable. An *extra* entry passes both -- and an extra entry is a
wrong identity in the other direction, since a non-nullable flag would
then swallow a `null` that npm treats as the package.

Pinned as a literal so a table change has to be a deliberate edit here
too. `derive_npm_flags.py --verify` re-derives it against a live npm;
this pins it without one (board review, adversarial seat).
"""
assert version_checker._NPM_NULLABLE_BOOLEAN_FLAGS == frozenset(
{
"--expect-results",
"--n",
"--no",
"--no-expect-results",
"--no-optional",
"--no-production",
"--no-workspaces",
"--no-yes",
"--optional",
"--production",
"--workspaces",
"--ws",
"--y",
"--yes",
"-n",
"-no",
"-ws",
"-y",
}
)

def test_true_and_false_are_consumed_by_any_boolean(self) -> None:
assert detect_package_type("npm", ["exec", "--global", "false", "a"]) == (
"npm",
"pkg",
"a",
)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.