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

## [Unreleased]

### Fixed
- **`gateway.update_server` now actually restarts the server, and no longer
leaves a stale "update available" notice behind after a successful
update.** Two compounding bugs:
- `update_server` called `gateway.refresh()` to activate the update, but
`refresh()` is a diff-based reconcile that deliberately leaves a server
whose resolved command/args are unchanged connected and running. A
version-only update (`npx pkg@latest`, `uvx --refresh pkg`) never
changes argv, so `refresh()` alone never respawned the process -- the
gateway kept serving the OLD package despite `update_server` reporting
success. `update_server` now explicitly restarts the target server
(reusing `gateway.restart_server`'s own resolve/disconnect/connect
machinery) and gates every downstream effect on that restart actually
succeeding; a refused or failed restart is now reported as `ok: false`
with a message explaining the update was fetched but not activated,
instead of silently claiming success.
- Separately, the recorded `version` (an upstream-latest snapshot from the
last `refresh`/describe pass, not the installed version) stayed pinned
at its pre-update value forever, because nothing in the update path ever
rewrote it. Every notice path (`gateway.describe`/`invoke`'s update
warning, `catalog_search`'s `stale_updates`, and the background stale
sweep) kept recomputing the identical stale notice from that value --
surviving even a restart, since the cache is reloaded from disk. Once
the server restart succeeds, the freshly-probed version -- along with
the tool list and generation timestamp, read live from the just-restarted
connection -- is now written into the descriptions cache and persisted
to disk, so both the notice and the offline tool listing stay accurate
across restarts.
- A third bug in the same area: `update_server` resolved its probe target
(the command it version-checks and updates) via a manifest/discovered-only
lookup, while the restart above resolves via the same precedence
`gateway.restart_server` uses, where a `.mcp.json` entry overrides the
manifest. For a server configured in both places -- README documents
pinning a server's exact version via `.mcp.json` as the supported
override channel -- this meant probing and version-checking one command
while restarting a different one. Concretely: manifest has an unpinned
`npx -y context7`, `.mcp.json` pins `npx -y context7@1.2.3`; the probe
detects/installs upstream's real latest, the restart activates the
*pinned* 1.2.3 process, and the (now-fixed) bookkeeping above would
record the probed "latest" as current -- silently misreporting the
pinned server's version. Both steps now resolve through the identical
function, so they can never disagree, and a server whose effective
config pins a concrete version is refused outright (`ok: false`,
explaining the pin and which config file it came from) rather than
probed and silently mis-recorded. Pin detection covers every package
manager the tool can update, not just npm: a `:tag`-pinned docker image
and a `cargo install --version` pin are refused the same way. Docker
needed its own handling because package detection strips the tag off the
image reference, so `docker run acme/server:1.2.3` would otherwise pull
`acme/server:latest`, restart the still-pinned config, and record the new
digest as active.

## [2.1.0] - 2026-08-12

### Added
Expand Down
162 changes: 112 additions & 50 deletions src/pmcp/manifest/version_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,50 @@ def _strip_npm_tag(package: str) -> str:
return name if tag_sep and name else package


def _npm_tag(package: str) -> str | None:
"""The npm dist-tag/version suffix ``_strip_npm_tag`` would discard, or
``None`` if *package* has no such suffix.

Mirrors ``_strip_npm_tag``'s own scope-aware ``rpartition`` exactly (same
guard conditions), so the two functions can never disagree about whether
a suffix is present or where it starts. Used by gateway.update_server's
pin detection.
"""
if package.startswith("@"):
scope, sep, remainder = package.partition("/")
if not sep:
return None
_name, tag_sep, tag = remainder.rpartition("@")
return tag if tag_sep and _name else None

_name, tag_sep, tag = package.rpartition("@")
return tag if tag_sep and _name else None


def _npm_package_arg(args: list[str]) -> str | None:
"""Return the raw npm/npx package token from *args*, or ``None``.

"Raw" means *before* ``_strip_npm_tag`` removes any ``@tag``/``@version``
suffix -- factored out of ``detect_package_type``'s npm branch so a
caller that needs the untouched token (gateway.update_server's pin
detection) can get it from the exact same scan ``detect_package_type``
itself uses, instead of re-implementing the scan a second time and
risking it picking a different argument as "the package".
"""
for arg in args:
if arg == "-y":
continue
# Skip flags
if arg.startswith("-"):
continue
# Found package name (might have @version or @dist-tag suffix)
pkg = _strip_npm_tag(arg)
# Handle scoped packages like @playwright/mcp
if pkg.startswith("@") or not pkg.startswith("-"):
return arg
return None


async def get_npm_version(package_name: str, timeout: float = 10.0) -> str | None:
"""
Get the latest version of an npm package.
Expand Down Expand Up @@ -231,17 +275,9 @@ def detect_package_type(
"""
if command in ("npx", "npm"):
# Find npm package in args (usually after -y flag)
for i, arg in enumerate(args):
if arg == "-y":
continue
# Skip flags
if arg.startswith("-"):
continue
# Found package name (might have @version or @dist-tag suffix)
pkg = _strip_npm_tag(arg)
# Handle scoped packages like @playwright/mcp
if pkg.startswith("@") or not pkg.startswith("-"):
return ("npm", pkg)
raw = _npm_package_arg(args)
if raw is not None:
return ("npm", _strip_npm_tag(raw))

elif command == "uvx":
# First non-flag argument is the package
Expand Down Expand Up @@ -276,51 +312,77 @@ def detect_package_type(
i += 1

elif command == "docker":
# docker run [options] image[:tag] [cmd...]
_value_flags = {
"-e",
"--env",
"-v",
"--volume",
"-p",
"--publish",
"--name",
"--network",
"-u",
"--user",
"--entrypoint",
"-w",
"--workdir",
"--label",
"-l",
"--memory",
"-m",
"--cpus",
"--add-host",
"--dns",
"--hostname",
"-h",
}
skip_next = False
for arg in args:
if skip_next:
skip_next = False
continue
if arg in ("run", "exec", "start", "create", "pull", "push"):
continue
if arg in _value_flags:
skip_next = True
continue
if arg.startswith("-"):
continue
# First positional arg after subcommand is the image name; strip tag
image = arg.split(":")[0]
raw = _docker_image_arg(args)
if raw is not None:
# Strip the tag: docker run [options] image[:tag] [cmd...]
image = raw.split(":")[0]
if image:
return ("docker", image)

return ("unknown", None)


def _docker_image_arg(args: list[str]) -> str | None:
"""Return the raw ``docker`` image token from *args*, or ``None``.

"Raw" means with any ``:tag`` suffix still attached -- factored out of
``detect_package_type``'s docker branch for the same reason
``_npm_package_arg`` was: gateway.update_server's pin detection needs the
untouched token, and re-implementing this scan separately would risk the
two disagreeing about which argument is "the image".
"""
_value_flags = {
"-e",
"--env",
"-v",
"--volume",
"-p",
"--publish",
"--name",
"--network",
"-u",
"--user",
"--entrypoint",
"-w",
"--workdir",
"--label",
"-l",
"--memory",
"-m",
"--cpus",
"--add-host",
"--dns",
"--hostname",
"-h",
}
skip_next = False
for arg in args:
if skip_next:
skip_next = False
continue
if arg in ("run", "exec", "start", "create", "pull", "push"):
continue
if arg in _value_flags:
skip_next = True
continue
if arg.startswith("-"):
continue
# First positional arg after the subcommand is the image reference.
return arg
return None


def _docker_image_tag(image_ref: str) -> str | None:
"""Return the ``:tag`` on a docker image reference, or ``None``.

Only the final path segment can carry a tag, so a registry host with a
port (``registry:5000/img:1.2.3``) does not read as a tag of ``5000``.
"""
last_segment = image_ref.rsplit("/", 1)[-1]
name, sep, tag = last_segment.partition(":")
return tag if sep and name and tag else None


async def get_package_version(
command: str, args: list[str], timeout: float = 10.0
) -> tuple[str | None, Literal["npm", "pypi", "cargo", "docker", "unknown"]]:
Expand Down
4 changes: 3 additions & 1 deletion src/pmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def __init__(
self._project_root = project_root
self._custom_config_path = custom_config_path
self._cache_dir = cache_dir or Path(".mcp-gateway")
self._descriptions_cache_path = get_cache_path(self._cache_dir)
self._host = host
self._port = port
self._auth_token = auth_token
Expand Down Expand Up @@ -185,6 +186,7 @@ def __init__(
project_root=project_root,
custom_config_path=custom_config_path,
guidance_config=self._guidance_config,
descriptions_cache_path=self._descriptions_cache_path,
)
if self._audit_jsonl is not None:
self._scoped_advisor_audit = ScopedAdvisorAudit(
Expand Down Expand Up @@ -652,7 +654,7 @@ async def initialize(self) -> None:
logger.info("Initializing MCP Gateway...")

# Load pre-built descriptions cache
cache_path = get_cache_path(self._cache_dir)
cache_path = self._descriptions_cache_path
self._descriptions_cache = load_descriptions_cache(cache_path)

if self._descriptions_cache:
Expand Down
Loading