From 4580c2a779cd571dc57c6c7f25e2704a17b3fb9f Mon Sep 17 00:00:00 2001 From: raymondginger Date: Wed, 21 Oct 2026 18:32:48 +0800 Subject: [PATCH 1/3] feat(cli): flag unpinned npx/uvx package specs in mcp list --- cli/mcp_cli.py | 65 ++++++++++++++++++++++++++++++++ tests/test_mcp_cli.py | 88 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/cli/mcp_cli.py b/cli/mcp_cli.py index 1d0f887a7..255a70c73 100644 --- a/cli/mcp_cli.py +++ b/cli/mcp_cli.py @@ -340,8 +340,57 @@ def _pairs(values: list[str], *, option: str) -> dict[str, str]: "off": "disabled", "env": "missing-env", "cred": "sends-credential", + "pin": "unpinned-package", } +#: Launchers whose argument names a registry package resolved at start time. +_UNPINNED_LAUNCHERS = frozenset({"npx", "uvx"}) + +#: Windows filename suffixes ``npx``/``uvx`` may be invoked with. +_LAUNCHER_SUFFIXES = (".cmd", ".exe", ".bat", ".ps1") + + +def _launcher_package_spec(args: list[str]) -> str | None: + """First registry-package token on a launcher command line, if any. + + Flags and URL values (``--index-url https://...``) are skipped, and a + path-like token (``./server.js``, ``/opt/x.js``, ``C:\\x.js``) names a + local file rather than a package, so there is no version to pin and + the scan stops. Only the first package token decides: later positional + arguments are the server's own operands (``npx -y pkg /data``). + """ + for arg in args: + if arg.startswith("-") or "://" in arg: + continue + if arg.startswith((".", "/", "\\")) or ( + len(arg) >= 2 and arg[1] == ":" and arg[0].isalpha() + ): + return None + return arg + return None + + +def _package_spec_is_unpinned(spec: str) -> bool: + """Whether a package spec floats: no version, or a non-numeric tag. + + ``pkg`` and ``pkg@latest`` (or ``pkg@next``) resolve to whatever the + registry serves now, so the effective code was never chosen; ``pkg@1.2.3`` + and pip's ``pkg==1.2.3`` name exact releases (an optional leading ``v`` + is accepted). A leading ``@scope/`` belongs to the name, so the ``@`` + delimiter is only searched past position 0. + """ + double_equals = spec.find("==") + if double_equals >= 0: + version = spec[double_equals + 2 :] + else: + at = spec.find("@", 1) if spec.startswith("@") else spec.find("@") + if at < 0: + return True + version = spec[at + 1 :] + if version.startswith("v"): + version = version[1:] + return not version or not version[0].isdigit() + def _mcp_concerns(server: dict[str, Any]) -> tuple[str, ...]: """Posture facts an operator should look at before trusting a server. @@ -353,6 +402,12 @@ def _mcp_concerns(server: dict[str, Any]) -> tuple[str, ...]: :func:`_posture_summary` states them once instead of repeating them on every row. Only key *names* are ever read, never credential values (see ``core.config._resolve_env_refs``). + + The package spec a stdio launcher runs is explicit configuration too: + ``npx``/``uvx`` given no version (or a floating tag such as + ``@latest``) fetch whatever the registry serves at each start, so that + choice is recorded on its row. Other commands run what they name and + are not scanned. """ concerns: list[str] = [] if not server["enabled"]: @@ -361,6 +416,16 @@ def _mcp_concerns(server: dict[str, Any]) -> tuple[str, ...]: concerns.append("env") if server["credentialEnvKeys"]: concerns.append("cred") + command = server["command"] + if command: + program = command.replace("\\", "/").rsplit("/", 1)[-1].lower() + for suffix in _LAUNCHER_SUFFIXES: + if program.endswith(suffix): + program = program[: -len(suffix)] + if program in _UNPINNED_LAUNCHERS: + spec = _launcher_package_spec(server["args"]) + if spec is not None and _package_spec_is_unpinned(spec): + concerns.append("pin") return tuple(concerns) diff --git a/tests/test_mcp_cli.py b/tests/test_mcp_cli.py index 65d023ed5..c373637b5 100644 --- a/tests/test_mcp_cli.py +++ b/tests/test_mcp_cli.py @@ -272,3 +272,91 @@ def test_mcp_list_names_concerns_and_states_default_posture_once( "Default posture among 3 enabled servers: " "2 expose all tools, 2 add no MCP approval gate." ) + + +def test_mcp_list_flags_unpinned_launcher_packages( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("DEEPCODE_HOME", str(home)) + (home / "deepcode_config.json").write_text( + json.dumps( + { + "mcpServers": { + "npx_tag": { + "type": "stdio", + "command": "npx", + "args": ["-y", "left-pad@latest"], + }, + "npx_noversion": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + }, + "npx_pinned": { + "type": "stdio", + "command": "npx", + "args": ["-y", "left-pad@1.2.3"], + }, + "npx_flags_only": { + "type": "stdio", + "command": "npx", + "args": ["--version"], + }, + "npx_local_file": { + "type": "stdio", + "command": "npx", + "args": ["./server.js"], + }, + "npx_windows": { + "type": "stdio", + "command": "C:\\Program Files\\nodejs\\npx.cmd", + "args": ["-y", "left-pad"], + }, + "uvx_noversion": { + "type": "stdio", + "command": "uvx", + "args": ["mcp-server-time"], + }, + "uvx_pinned": { + "type": "stdio", + "command": "uvx", + "args": ["mcp-server-time==0.3.0"], + }, + "python": { + "type": "stdio", + "command": "python3", + "args": ["server.py"], + }, + } + } + ), + encoding="utf-8", + ) + + assert run(["list"]) == 0 + header, *rows, summary = capsys.readouterr().out.splitlines() + assert header.split() == ["CONFIG", "AUTH", "RUNTIME", "SCOPE", "NAME", "CONCERNS"] + concerns: dict[str, str] = {} + for row in rows: + fields = row.split(maxsplit=5) + concerns[fields[4]] = fields[5] + assert summary.startswith("Default posture among 9 enabled servers") + # ``npx``/``uvx`` resolve their package argument from the registry on + # every start, so a missing version or a floating tag is the concern; + # a pinned version, a local file, a non-launcher command and a command + # line without a package are not. + assert concerns == { + "npx_tag": "unpinned-package", + "npx_noversion": "unpinned-package", + "npx_pinned": "-", + "npx_flags_only": "-", + "npx_local_file": "-", + "npx_windows": "unpinned-package", + "uvx_noversion": "unpinned-package", + "uvx_pinned": "-", + "python": "-", + } From 5ce349a5e96c5317bc4b929c2a82c172dd4d046d Mon Sep 17 00:00:00 2001 From: raymondginger Date: Wed, 21 Oct 2026 19:38:22 +0800 Subject: [PATCH 2/3] test(cli): derive mcp posture denominator from printed rows The posture line counts only servers whose configuration resolves, and launcher availability differs by host (Windows path / uvx missing on CI), so a hardcoded 9 failed upstream while passing locally. Derive the count from the CONFIG column instead; the concerns map assertion is unchanged. --- tests/test_mcp_cli.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp_cli.py b/tests/test_mcp_cli.py index c373637b5..cee371fc0 100644 --- a/tests/test_mcp_cli.py +++ b/tests/test_mcp_cli.py @@ -344,7 +344,14 @@ def test_mcp_list_flags_unpinned_launcher_packages( for row in rows: fields = row.split(maxsplit=5) concerns[fields[4]] = fields[5] - assert summary.startswith("Default posture among 9 enabled servers") + # The posture line counts only enabled servers whose configuration + # resolves, and command availability differs by host (a Windows path or + # ``uvx`` may be absent here), so derive the denominator from the rows. + readable = sum( + 1 for row in rows if row.split(maxsplit=1)[0] not in {"invalid", "disabled"} + ) + assert readable >= 1 + assert summary.startswith(f"Default posture among {readable} enabled servers") # ``npx``/``uvx`` resolve their package argument from the registry on # every start, so a missing version or a floating tag is the concern; # a pinned version, a local file, a non-launcher command and a command From 1767abb42a918f320d595ca7fac1e21f46efd1be Mon Sep 17 00:00:00 2001 From: raymondginger Date: Sat, 24 Oct 2026 01:44:06 +0800 Subject: [PATCH 3/3] fix(cli): read the mcp list package from launcher options, pin npm fully The scan skipped flags but consumed their values as positionals: `uvx --python 3.12 pkg==0.3.0` and `uvx --with httpx pkg==0.3.0` were flagged for the option value instead of the package, and `npx --cache /tmp/c pkg` stopped at the path and missed the real concern. Read the package from `--package`/`-p` (npx) and `--from` (uvx) when present, and consume the values of other value-taking options (`--cache`, `--registry`, `--call`/ `-c`; `--python`, `-p`, `--with`, `--index-url`). npm reads a partial version as a range (`pkg@1` means every `1.x`), so count only a full `x.y.z` with an optional pre-release suffix as pinned for npx; uvx's `==` matches exactly at any precision and stays as it was. Per review comment 6336671036. --- cli/mcp_cli.py | 70 ++++++++++++++++++++++++++++++++++++------- tests/test_mcp_cli.py | 66 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/cli/mcp_cli.py b/cli/mcp_cli.py index 255a70c73..ee56fbe7a 100644 --- a/cli/mcp_cli.py +++ b/cli/mcp_cli.py @@ -4,6 +4,7 @@ import argparse import json +import re import sys from pathlib import Path from typing import Any @@ -349,18 +350,59 @@ def _pairs(values: list[str], *, option: str) -> dict[str, str]: #: Windows filename suffixes ``npx``/``uvx`` may be invoked with. _LAUNCHER_SUFFIXES = (".cmd", ".exe", ".bat", ".ps1") +#: Options whose own value names the package, per launcher. +_PACKAGE_OPTIONS = { + "npx": ("--package", "-p"), + "uvx": ("--from",), +} + +#: Options that take a value which is not the package, per launcher; the +#: next bare token is consumed as that value and never scanned as a package. +_VALUE_OPTIONS = { + "npx": frozenset({"--cache", "--registry", "--call", "-c"}), + "uvx": frozenset({"--python", "-p", "--with", "--index-url"}), +} + +#: npm reads a partial version (``pkg@1``, ``pkg@1.x``) as a range, so only +#: a full ``x.y.z`` with an optional pre-release suffix pins it down. +_NPM_EXACT_VERSION = re.compile(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?") -def _launcher_package_spec(args: list[str]) -> str | None: + +def _launcher_package_spec(args: list[str], launcher: str) -> str | None: """First registry-package token on a launcher command line, if any. - Flags and URL values (``--index-url https://...``) are skipped, and a - path-like token (``./server.js``, ``/opt/x.js``, ``C:\\x.js``) names a - local file rather than a package, so there is no version to pin and - the scan stops. Only the first package token decides: later positional + The package may arrive as a positional argument or through the + launcher's own option (``--package``/``-p`` for ``npx``, ``--from`` + for ``uvx``); the option's value decides, inline ``--from=x`` alike. + Any other option that takes a value (``--cache``, ``--with``, + ``--python``) consumes its value without scanning it, so a value can + never be mistaken for the package (``uvx --with httpx pkg`` scans + ``pkg``, not ``httpx``), while a valueless flag (``-y``) consumes + nothing. URL tokens are skipped, and a path-like token + (``./server.js``, ``/opt/x.js``, ``C:\\x.js``) names a local file + rather than a package, so there is no version to pin and the scan + stops. Only the first package token decides: later positional arguments are the server's own operands (``npx -y pkg /data``). """ + package_options = _PACKAGE_OPTIONS.get(launcher, ()) + value_options = _VALUE_OPTIONS.get(launcher, frozenset()) + expect: str | None = None # next token is a package value or a skipped value for arg in args: - if arg.startswith("-") or "://" in arg: + if expect is not None: + if expect == "package": + return arg + expect = None + continue + if arg.startswith("-"): + name, eq, inline = arg.partition("=") + if name in package_options: + if eq: + return inline + expect = "package" + elif name in value_options and not eq: + expect = "skip" + continue + if "://" in arg: continue if arg.startswith((".", "/", "\\")) or ( len(arg) >= 2 and arg[1] == ":" and arg[0].isalpha() @@ -370,13 +412,17 @@ def _launcher_package_spec(args: list[str]) -> str | None: return None -def _package_spec_is_unpinned(spec: str) -> bool: - """Whether a package spec floats: no version, or a non-numeric tag. +def _package_spec_is_unpinned(spec: str, launcher: str) -> bool: + """Whether a package spec floats: no version, or not one exact release. ``pkg`` and ``pkg@latest`` (or ``pkg@next``) resolve to whatever the registry serves now, so the effective code was never chosen; ``pkg@1.2.3`` and pip's ``pkg==1.2.3`` name exact releases (an optional leading ``v`` - is accepted). A leading ``@scope/`` belongs to the name, so the ``@`` + is accepted). npm reads a partial version as a range (``pkg@1`` means + every ``1.x``), so ``npx`` counts only a full ``x.y.z`` -- optionally + with a pre-release suffix such as ``-beta.1`` -- as pinned; uvx's + ``==`` matches exactly at any precision, so a leading digit still + counts there. A leading ``@scope/`` belongs to the name, so the ``@`` delimiter is only searched past position 0. """ double_equals = spec.find("==") @@ -389,6 +435,8 @@ def _package_spec_is_unpinned(spec: str) -> bool: version = spec[at + 1 :] if version.startswith("v"): version = version[1:] + if launcher == "npx": + return _NPM_EXACT_VERSION.fullmatch(version) is None return not version or not version[0].isdigit() @@ -423,8 +471,8 @@ def _mcp_concerns(server: dict[str, Any]) -> tuple[str, ...]: if program.endswith(suffix): program = program[: -len(suffix)] if program in _UNPINNED_LAUNCHERS: - spec = _launcher_package_spec(server["args"]) - if spec is not None and _package_spec_is_unpinned(spec): + spec = _launcher_package_spec(server["args"], program) + if spec is not None and _package_spec_is_unpinned(spec, program): concerns.append("pin") return tuple(concerns) diff --git a/tests/test_mcp_cli.py b/tests/test_mcp_cli.py index cee371fc0..5587af752 100644 --- a/tests/test_mcp_cli.py +++ b/tests/test_mcp_cli.py @@ -331,6 +331,56 @@ def test_mcp_list_flags_unpinned_launcher_packages( "command": "python3", "args": ["server.py"], }, + "npx_cache_value": { + "type": "stdio", + "command": "npx", + "args": ["--cache", "/tmp/c", "-y", "left-pad"], + }, + "npx_package_option_pinned": { + "type": "stdio", + "command": "npx", + "args": ["--package", "left-pad@1.2.3", "-y"], + }, + "npx_package_short_unpinned": { + "type": "stdio", + "command": "npx", + "args": ["-y", "-p", "left-pad", "cowsay@2.0.0"], + }, + "npx_major_only": { + "type": "stdio", + "command": "npx", + "args": ["-y", "left-pad@1"], + }, + "npx_minor_wild": { + "type": "stdio", + "command": "npx", + "args": ["-y", "left-pad@1.x"], + }, + "npx_prerelease": { + "type": "stdio", + "command": "npx", + "args": ["-y", "left-pad@1.2.3-beta.1"], + }, + "uvx_python_option": { + "type": "stdio", + "command": "uvx", + "args": ["--python", "3.12", "mcp-server-time==0.3.0"], + }, + "uvx_python_short": { + "type": "stdio", + "command": "uvx", + "args": ["-p", "3.12", "mcp-server-time==0.3.0"], + }, + "uvx_with_option": { + "type": "stdio", + "command": "uvx", + "args": ["--with", "httpx", "mcp-server-time==0.3.0"], + }, + "uvx_from_option": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "mcp-server-time==0.3.0"], + }, } } ), @@ -355,7 +405,11 @@ def test_mcp_list_flags_unpinned_launcher_packages( # ``npx``/``uvx`` resolve their package argument from the registry on # every start, so a missing version or a floating tag is the concern; # a pinned version, a local file, a non-launcher command and a command - # line without a package are not. + # line without a package are not. The package is read from the + # launcher's own option when one names it, other options' values are + # skipped rather than scanned, and npm's partial versions (``@1``, + # ``@1.x``) are ranges -- only a full ``x.y.z``, with an optional + # pre-release suffix, counts as pinned there. assert concerns == { "npx_tag": "unpinned-package", "npx_noversion": "unpinned-package", @@ -363,7 +417,17 @@ def test_mcp_list_flags_unpinned_launcher_packages( "npx_flags_only": "-", "npx_local_file": "-", "npx_windows": "unpinned-package", + "npx_cache_value": "unpinned-package", + "npx_package_option_pinned": "-", + "npx_package_short_unpinned": "unpinned-package", + "npx_major_only": "unpinned-package", + "npx_minor_wild": "unpinned-package", + "npx_prerelease": "-", "uvx_noversion": "unpinned-package", "uvx_pinned": "-", + "uvx_python_option": "-", + "uvx_python_short": "-", + "uvx_with_option": "-", + "uvx_from_option": "-", "python": "-", }