#!/usr/bin/env python3
"""Read-only MCP config scanner for the marketing-mcp-setup skill.

The tool drift check is scripts/mcp_tools_diff.py and the inventory check is
scripts/mcp_inventory_check.py; each script stands alone.

Usage (from the skill folder):
  python3 -I scripts/mcp_inventory.py [CONFIG ...] [--discover] [--project DIR]
      [--all-projects] [--fixture FILE] [--registry FILE]
      [--expect-region VENDOR=REGION[,VENDOR=REGION]] [--as-of YYYY-MM-DD] [--json]

Reads MCP client config files (paths you pass, the known locations with
--discover, or the virtual files in a --fixture JSON) and reports:
S01 route (official, third party, community, vendor package missing from the
registry, lookalike, unknown), S02 retired, deprecated or legacy server,
S03 inline secret, S04 credential in a URL, S05 unpinned install,
S06 read-only setting missing, S07 wrong regional endpoint, S08 plain HTTP or
SSE, S09 wrong top-level key, S10 same server in several scopes, S11
write-capable server in a shared file, S12 credential file inside the
project, S13 proxy wrapper, S14 project servers approved without a prompt,
S15 settings that let tool calls run without a person.

--discover reads, when present: ~/.claude.json, <project>/.mcp.json,
managed-mcp.json, the terminal agent's settings files (user, project, project
local and managed; the approval keys, and managedMcpServers from managed
settings), claude_desktop_config.json, .cursor/mcp.json (project and home),
.vscode/mcp.json, the VS Code user mcp.json and the mcp.servers block of its
user settings.json, ~/.copilot/mcp-config.json and .codex/config.toml (project
and home). --project is the repository whose project files to read (default:
the current directory). With --discover, --all-projects also reads the project
files of every other repository listed in ~/.claude.json and prints the
repositories read; on its own it adds those repositories' local-scope entries
from a ~/.claude.json you pass.

It never reads .env files, credential files, keychains or browser storage, and
never prints secret values it can recognise. Env and header values are not
shown. An argument prints only when it reads as a flag, a path, a package spec,
a number or a plain lower-case word; values after a credential flag, key-like
values, connection strings, quoted commands and JSON print as [redacted], and
so do URL user info, query values that are not plain settings and random path
segments in URLs of any scheme. Parser messages are cut to a line number, and
every line passes one last filter on the way out. A plain lower-case word after
a flag with a neutral name still prints: nothing marks it as a credential.
Standard library only. No network calls, no subprocesses, no file writes.
TOML configs use tomllib on Python 3.11 or later and a small built-in reader on
older versions. --as-of defaults to today; pass it only to reproduce a dated
example. Exit codes: 0 no FAIL, 1 at least one FAIL, 2 bad input or a file not
read, so the scan is incomplete.
"""

import argparse
import datetime as dt
import json
import os
import re
import shlex
import sys
from urllib.parse import parse_qsl, urlsplit

try:
    import tomllib  # Python 3.11 and later
except ImportError:  # older Python: MiniToml below
    tomllib = None

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_REGISTRY = os.path.normpath(os.path.join(HERE, "..", "assets", "server-registry.json"))
ORDER = {"FAIL": 0, "WARN": 1, "INFO": 2, "PASS": 3}

SENSITIVE = re.compile(
    r"authorization|bearer|token|secret|passw(or)?d|api[_-]?key|private[_-]?key|access[_-]?key|credential",
    re.I,
)
KEYISH = re.compile(r"(^|[_-])(key|pk|sk|pat|pass)$|[_-]pwd$", re.I)  # PWD alone is the working directory
AUTHISH = re.compile(r"(^|[_-])(auth|pat)([_-]|$)", re.I)
ID_KEY = re.compile(r"(^|[_-])(id|ids|name|names|region|project|host|url|uri)$", re.I)
SENSITIVE_QUERY = {
    "key", "api_key", "apikey", "api-key", "user_api_key", "token", "access_token",
    "auth", "secret", "client_secret", "password", "passwd", "pwd", "pass", "private_key",
}
CREDENTIAL_PATH_KEY = re.compile(
    r"^(GOOGLE_APPLICATION_CREDENTIALS|GOOGLE_SERVICE_ACCOUNT_KEY|GSC_TOKEN_FILE"
    r"|.*_KEY_FILE|.*_KEY_PATH|.*_CREDENTIALS|.*_CREDENTIALS_FILE|.*_CREDENTIALS_PATH"
    r"|.*_TOKEN_FILE|.*_SECRET_FILE|.*_CLIENT_FILE)$",
    re.I,
)
CREDENTIAL_PATH_ARG = re.compile(
    r"^--?([a-z0-9-]*(client|credential|key|token|secret|service-account)[a-z0-9-]*-(file|path)|credentials?)$",
    re.I,
)
SENSITIVE_ARG = re.compile(
    r"^--?[a-z0-9_-]*(token|secret|passw(or)?d|passphrase|api[_-]?key|private[_-]?key|access[_-]?key|bearer|authorization)[a-z0-9_-]*$",
    re.I,
)
VALUE_FLAG = re.compile(
    r"^(--?([a-z0-9]+[-_])*(key|pat|auth|token|secret|pass|pwd|passwd|password|passcode|credentials?|cookie)|-[kp])$", re.I)
NOT_A_VALUE_ARG = re.compile(r"(file|path|dir|env|var|name)$", re.I)
LITERAL_ONLY_FLAG = re.compile(r"token|secret|pass|pwd|bearer|cookie", re.I)  # a path after these is still a literal
CONNECTION_FLAG = re.compile(
    r"^--?([a-z0-9]+[-_])*(dsn|conn|connection|conn[-_]str|(conn|connection)[-_]string|(db|database)[-_](url|uri))$", re.I)
AUTH_WORDS = {"oauth", "oauth2", "none", "basic", "bearer", "token", "apikey", "api-key", "key", "pat", "sso", "adc",
              "env", "browser", "device", "user", "service-account", "iam"}
FLAG_SHAPE = re.compile(r"^(--[a-z0-9][a-z0-9-]*(=.*)?|-[A-Za-z])$")  # after a credential flag, anything else is its value
PORTISH = re.compile(r"^[\d.:]+$")  # a port, address:port or version
KV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
URL_START = re.compile(r"^((?:[a-z][a-z0-9+.-]*:)+)//", re.I)  # any scheme, or a chain such as jdbc:postgresql://
ID_FLAG = re.compile(r"(^|[-_])ids?$", re.I)
EXACT_VERSION = re.compile(r"^v?\d+(\.\d+){1,3}([-+][0-9A-Za-z.-]+)?$")
COMMIT = re.compile(r"^[0-9a-f]{7,40}$")
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
SECRETISH_SEGMENT = re.compile(r"^(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_-]{20,}$")
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
JWT_RE = re.compile(r"^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(\.[A-Za-z0-9_-]*)?$")
SCHEME_VALUE = re.compile(r"^(bearer|basic)\s+\S", re.I)
BOOLEANISH = re.compile(r"^(true|false|yes|no|on|off|none|null|0|1)$", re.I)
OPTION_WORD = re.compile(r"^[a-z][a-z-]{0,23}$")
PLAIN_VALUE = re.compile(r"^[a-z0-9._+=-]{1,40}$")  # the only literal arguments that print
LETTERS_AND_DIGITS = re.compile(r"^(?=.*[a-z])(?=.*\d).{8,}$|[a-z0-9]{16}")  # a key-like word: an unbroken run counts too
PLAIN_SETTINGS = {"tools", "progressive"}  # registry check values that are neither boolean nor numeric
PATH_SECRET = re.compile(r"^(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_-]{12,}$")
USER_PASS_AT = re.compile(r"^[^\s/@:]+:[^\s/@]+@[^\s@]+$")  # user:password@host with no scheme
BEARER_TEXT = re.compile(r"\b(?:bearer|basic)\s+(?!\$\{)[A-Za-z0-9._~+/=-]{6,}", re.I)
INLINE_SECRET = re.compile(
    r"(?:^|[\s;&,?#'\"{(])[\w.-]*(?:passw(?:or)?d|passphrase|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)[\w.-]*"
    r"['\"]?\s*[=:]\s*['\"]?([^\s;&,'\"]+)",
    re.I,
)
OUT_USERINFO = re.compile(r"([a-z][a-z0-9+.-]*://)(?!\[redacted\])[^\s/@\"]+@", re.I)
OUT_BEARER = re.compile(r"\b(Bearer|Basic)\s+(?!\[redacted\])[A-Za-z0-9._~+/=-]{6,}")
OUT_PAIR = re.compile(r"\b([\w.-]*(?:passw(?:or)?d|pwd|secret|token|api[_-]?key)[\w.-]*=)(?!\[redacted\]|\$\{)[^\s;&,'\"]+", re.I)
HEADER_FLAGS = ("--header", "-H")
APPROVAL_MODES = {"auto", "prompt", "writes", "approve"}
SKIPS_APPROVAL = {"auto", "approve"}
WRITE_TOOL = re.compile(r"^(?:[a-z0-9]+_)?(?:manage|create|update|delete|send|publish|merge|add|remove|write|edit|set|"
                        r"upload|launch|activate|submit|share|trigger|bulk)(?:_|$)", re.I)
CLAUDE_ORDER = ["local", "project", "user"]


class InputError(Exception):
    """Bad arguments or unreadable input: exit code 2."""


# ---------------------------------------------------------------- small helpers

def plural(n, word):
    return f"{n} {word}" if n == 1 else f"{n} {word}s"


def load_json_file(path, what):
    try:
        with open(path, encoding="utf-8") as handle:
            return json.load(handle)
    except FileNotFoundError:
        raise InputError(f"{what} file not found: {path}")
    except (OSError, ValueError) as exc:
        raise InputError(f"cannot read {what} file {path}: {exc}")


def parse_date(value, label):
    try:
        return dt.date.fromisoformat(value)
    except (TypeError, ValueError):
        raise InputError(f"{label} must be YYYY-MM-DD, got {value!r}")


def norm(path):
    return os.path.normpath(path).replace("\\", "/") if path else path


def version_key(text):
    """Numeric parts of a plain version, or None when it is not one."""
    return tuple(int(part) for part in text.split(".")) if re.fullmatch(r"\d+(?:\.\d+)*", text or "") else None


def inside(path, root):
    if not path or not root:
        return False
    p, r = norm(path), norm(root).rstrip("/")
    return p == r or p.startswith(r + "/")


def show_path(path, home):
    """Replace the home directory with ~ so output never carries a user name."""
    if not path:
        return path
    p, h = norm(path), norm(home).rstrip("/") if home else ""
    if h and (p == h or p.startswith(h + "/")):
        return "~" + p[len(h):]
    return p


def expand_home(path, home):
    if path == "~" or path.startswith("~/"):
        return home.rstrip("/") + path[1:]
    return path


def is_reference(value):
    return isinstance(value, str) and "${" in value and "}" in value


def literal_part(value):
    """What is left of a value once its ${...} references are removed: 'Bearer' for 'Bearer ${TOKEN}'."""
    return re.sub(r"\$\{[^{}]*\}", "", value or "").strip()


def looks_like_path(value):
    v = (value or "").strip()
    return (
        v.startswith(("/", "./", "../", "~", ".\\", "..\\"))
        or re.match(r"^[A-Za-z]:[\\/]", v) is not None
        or v.lower().endswith((".json", ".pem", ".p8", ".p12", ".key"))
    )


# ---------------------------------------------------------------- secrets and redaction

def looks_like_secret(value):
    """A literal that reads as a key or token: a UUID, a JWT, or a long random-looking string."""
    v = (value or "").strip()
    if not v or is_reference(v) or looks_like_path(v) or "://" in v:
        return False
    if JWT_RE.match(v) or UUID_RE.search(v):
        return True
    if not SECRETISH_SEGMENT.match(v):
        return False
    mixed_case = re.search(r"[a-z]", v) and re.search(r"[A-Z]", v)
    long_run = any(len(run) >= 16 and re.search(r"\d", run) and re.search(r"[A-Za-z]", run)
                   for run in re.split(r"[-_]", v))
    return bool(mixed_case or long_run)


def secretish(value):
    """Wider net for URL query values and path segments, where over-redaction costs nothing."""
    v = (value or "").strip()
    return bool(v) and not is_reference(v) and (SECRETISH_SEGMENT.match(v) is not None or looks_like_secret(v))


def embedded_secret(value):
    """A password, token or key inside a longer string: a keyword connection string, a ;password= parameter,
    a Bearer value or user:password@host without a scheme."""
    v = value or ""
    if USER_PASS_AT.match(v) or BEARER_TEXT.search(v):
        return True
    return any(not is_reference(found.group(1)) for found in INLINE_SECRET.finditer(v))


def split_userinfo(rest):
    """(user info, the remainder) of the text after scheme://. An unescaped / ? or # in a password hides the @ from
    urlsplit, so an authority with a port that is not a number and a later @ counts as user info too."""
    authority = re.split(r"[/?#]", rest, maxsplit=1)[0]
    if "@" in authority:
        at = authority.rfind("@")
        return authority[:at], rest[at + 1:]
    head, colon, tail = authority.rpartition(":")
    if colon and head and not tail.isdigit() and not authority.startswith("[") and "@" in rest:
        at = rest.rfind("@")
        return rest[:at], rest[at + 1:]
    return "", rest


def url_credential(value):
    """Where a URL of any scheme carries a literal password or token ('user info', 'query key ...' or
    'a connection parameter'), else None. A user name alone, as in an SSH git URL, and ${...} references do not count."""
    v = (value or "").strip()
    start = URL_START.match(v)
    if not start:
        return None
    user_info, rest = split_userinfo(v[start.end():])
    if user_info:
        user, colon, password = user_info.partition(":")
        if colon and password and not is_reference(password):
            return "user info"
        if not colon and user and not is_reference(user) and secretish(user):
            return "user info"
    query = rest.split("#", 1)[0].partition("?")[2]
    for key, val in parse_qsl(query, keep_blank_values=True):
        if val and not is_reference(val) and (key.lower() in SENSITIVE_QUERY or SENSITIVE.search(key)):
            return f"query key {key}"
    return "a connection parameter" if embedded_secret(rest) else None


def env_secret(key, value):
    """True when an env value, header value or KEY=value argument holds a literal credential."""
    v = (value or "").strip()
    if not v:
        return False
    if url_credential(v):
        return True  # checked first: it judges each ${...} reference in the URL on its own
    if is_reference(v):
        return False
    if CREDENTIAL_PATH_KEY.match(key) and looks_like_path(v):
        return False
    if SCHEME_VALUE.match(v) or v.startswith("{") or embedded_secret(v):
        return True
    if SENSITIVE.search(key) or KEYISH.search(key):
        return BOOLEANISH.match(v) is None
    if AUTHISH.search(key) and len(v) >= 20:
        return True
    return looks_like_secret(v) and not ID_KEY.search(key)


def header_secret(name, value):
    v = (value or "").strip()
    if not v or is_reference(v):
        return False
    if name.strip().lower() in ("x-api-key", "api-key", "cookie"):
        return True
    return env_secret(name.strip(), v)


def takes_secret(flag):
    if CONNECTION_FLAG.match(flag):
        return True
    return bool((SENSITIVE_ARG.match(flag) or VALUE_FLAG.match(flag)) and not NOT_A_VALUE_ARG.search(flag))


def flag_verdict(flag, value):
    """What to do with the value after a credential flag: 'show' it, 'hide' it, or call it a 'secret' (hide it and FAIL)."""
    v = (value or "").strip()
    if not literal_part(v) or re.fullmatch(r"bearer|basic", literal_part(v), re.I):
        return "show"  # nothing but references
    if BOOLEANISH.match(v) or URL_START.match(v):
        return "show"  # a URL is checked for user info and query credentials instead
    if looks_like_path(v):
        if not LITERAL_ONLY_FLAG.search(flag) or v.startswith(("./", "../", "~/")) or v.lower().endswith((".json", ".pem", ".key")):
            return "show"
        if v.count("/") > 1:
            return "hide"  # /run/secrets/token after --token is a path, but /Ab3/xyz can be a base64 value
    if re.search(r"(^|[-_])auth$", flag.lstrip("-"), re.I) and OPTION_WORD.match(v):
        return "show" if v in AUTH_WORDS else "hide"  # --auth oauth is an option; another word may not be
    if flag == "-p" and PORTISH.match(v):
        return "show"  # -p 5432 or -p 3.12: a port or version
    if flag == "-p" and OPTION_WORD.match(v):
        return "hide"  # -p default may be a name, so it is not a finding
    if CONNECTION_FLAG.match(flag) and not embedded_secret(v):
        return "hide"  # --dsn name may be a data source name
    return "secret"


def echo(value, want=""):
    """A setting's value for a message: printed only when it cannot be a credential."""
    v = str(value)
    plain = v == want or BOOLEANISH.match(v) or (v.isdigit() and len(v) <= 12) or v in PLAIN_SETTINGS
    return v if plain else "[redacted]"


def redact_pair(key, value):
    sensitive = key.lower() in SENSITIVE_QUERY or SENSITIVE.search(key)
    if value and (sensitive or (echo(value) == "[redacted]" and literal_part(value))):
        return f"{key}=[redacted]"
    return f"{key}={value}"


def redact_url(url):
    """Print the host and query keys of a URL of any scheme. User info, connection parameters, query values that are
    not plain settings and random-looking path segments are replaced; a git commit or version after # stays visible."""
    if not url:
        return url
    outer, start = "", URL_START.match(url)
    if start and start.group(1).count(":") > 1:  # jdbc:postgresql://... -> keep jdbc: aside
        outer = start.group(1)[:start.group(1)[:-1].rfind(":") + 1]
        url = url[len(outer):]
    start = URL_START.match(url)
    if start:
        user_info, rest = split_userinfo(url[start.end():])
        if user_info:
            url = f"{url[:start.end()]}user@{rest}"
    try:
        parts = urlsplit(url)
    except ValueError:
        return "[unparseable URL]"
    netloc = parts.netloc
    if "@" in netloc:
        netloc = "[redacted]@" + netloc.rsplit("@", 1)[1]
    if ";" in netloc:
        netloc = netloc.split(";", 1)[0] + ";[redacted]"
    segments = []
    for seg in parts.path.split("/"):
        if ";" in seg:
            seg = seg.split(";", 1)[0] + ";[redacted]"
        segments.append("[redacted]" if secretish(seg) or PATH_SECRET.match(seg) else seg)
    pairs = [redact_pair(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)]
    query = ("?" + "&".join(pairs)) if pairs else ""
    fragment = parts.fragment
    if fragment and "=" in fragment:
        fragment = "&".join(redact_pair(k, v) for k, v in parse_qsl(fragment, keep_blank_values=True))
    elif fragment and not (COMMIT.match(fragment) or EXACT_VERSION.match(fragment)):
        fragment = "[redacted]"
    return f"{outer}{parts.scheme}://{netloc}{'/'.join(segments)}{query}" + (f"#{fragment}" if fragment else "")


def safe_text(value):
    """A package spec or other value for a message: URLs lose user info and query credentials."""
    v = value or ""
    if re.search(r"\s", v.strip()) or USER_PASS_AT.match(v):
        return "[redacted]"
    return redact_url(v) if URL_START.match(v) else v


def redact_header(value):
    return value.split(":", 1)[0] + ": [redacted]" if ":" in value else "[redacted]"


def plain_word(value):
    return not value or (PLAIN_VALUE.match(value) is not None and not LETTERS_AND_DIGITS.search(value))


def display_value(value, home):
    """A literal for the inventory. Anything that is not a URL, a path, a reference or a plain word stays hidden."""
    v = value or ""
    if URL_START.match(v):
        return redact_url(v)
    if is_reference(v):
        return v if plain_word(literal_part(v)) else "[redacted]"
    if v.startswith(("/", "~")):
        return show_path(v, home)
    if looks_like_path(v) or PORTISH.match(v) or BOOLEANISH.match(v):
        return v
    return v if plain_word(v) else "[redacted]"


def command_is_path(command):
    c = (command or "").strip()
    return looks_like_path(c) and "=" not in c and " -" not in c


def display_command(command, home):
    c = (command or "").strip()
    if re.search(r"\s", c) and not command_is_path(c):
        return "[redacted]"
    return show_path(c, home)


def shell_hits(text, home):
    """S03 hit for a quoted command or a keyword connection string; the string itself is never printed."""
    try:
        tokens = shlex.split(text)
    except ValueError:
        tokens = text.split()
    return ["a quoted command or connection string"] if walk_args(tokens, home)[1] or embedded_secret(text) else []


def shown_literal(value, home, where, url_arg=None, is_id=False, is_spec=False):
    """Display form of one argument value, and the S03 hits it carries."""
    v = value.strip()
    if re.search(r"\s", v):
        return "[redacted]", shell_hits(v, home)
    if URL_START.match(v):
        return redact_url(v), [f"a connection string in {where}"] if v != url_arg and url_credential(v) else []
    if is_spec:
        return v, []
    if v.startswith("{"):
        return "[redacted]", [where] if embedded_secret(v) or (SENSITIVE.search(v) and not is_reference(v)) else []
    if USER_PASS_AT.match(v) and not is_reference(v):
        return "[redacted]", [f"a connection string in {where}"]
    key, eq, rest = v.partition("=")
    if eq and KV_NAME.match(key):
        if env_secret(key, rest):
            return f"{key}=[redacted]", [f"argument {key}=..."]
        return f"{key}={display_value(rest, home)}", []
    if is_id:
        return v, []
    if looks_like_secret(v):
        return "[redacted]", [where]
    return display_value(v, home), []


def header_arg(args, i):
    """The 'Name: value' text of a header argument at position i, and how many arguments it takes."""
    arg = args[i]
    if arg in HEADER_FLAGS:
        return (args[i + 1], 2) if i + 1 < len(args) else (None, 0)
    for flag in HEADER_FLAGS:
        if arg.startswith(flag + "="):
            return arg.split("=", 1)[1], 1
    return (arg[2:], 1) if re.match(r"^-H[A-Za-z]", arg) else (None, 0)


def walk_args(args, home, specs=(), url_arg=None):
    """Display form of a launch's arguments, plus which arguments carry a literal secret.
    url_arg is the URL a proxy wrapper forwards to; S04 reports its credentials, so it adds no hit here."""
    out, hits, i, prev_flag, spec_set = [], [], 0, "", set(specs)
    positions = [n for n, a in enumerate(args) if a in spec_set]
    server_start = positions[-1] + 1 if positions else 0  # -p before the package belongs to the launcher

    def literal(value, where, is_id=False, is_spec=False):
        shown, found = shown_literal(value, home, where, url_arg, is_id, is_spec)
        hits.extend(found)
        return shown

    def after_flag(name, value):
        verdict = "show" if value in spec_set else flag_verdict(name, value)
        if verdict == "show":
            return literal(value, f"argument {name}")
        if verdict == "secret":
            hits.append(f"argument {name}")
        return "[redacted]"

    while i < len(args):
        arg = args[i]
        header, step = header_arg(args, i)
        if header is not None:
            flag = arg if arg in HEADER_FLAGS else arg[:2] if not arg.startswith("--") else "--header"
            out += [flag, redact_header(header)] if step == 2 else [f"{flag}={redact_header(header)}"]
            if ":" not in header and not is_reference(header):
                hits.append(f"argument {flag}")
            i, prev_flag = i + step, ""
            continue
        if re.search(r"\s", arg.strip()):
            out.append("[redacted]")
            hits.extend(shell_hits(arg, home))
            i, prev_flag = i + 1, ""
            continue
        if arg.startswith("-") and len(arg) > 1:
            name, eq, value = arg.partition("=")
            secret_flag = takes_secret(name) and (name != "-p" or i >= server_start)
            if eq:
                shown = after_flag(name, value) if secret_flag else literal(value, f"argument {name}", bool(ID_FLAG.search(name)))
                out.append(f"{name}={shown}")
                i, prev_flag = i + 1, ""
                continue
            out.append("[redacted]" if looks_like_secret(arg.lstrip("-")) else arg)
            if secret_flag and i + 1 < len(args) and not FLAG_SHAPE.match(args[i + 1]):
                out.append(after_flag(name, args[i + 1]))
                i, prev_flag = i + 2, ""
                continue
            i, prev_flag = i + 1, name
            continue
        is_id = bool(prev_flag and ID_FLAG.search(prev_flag))
        where = f"argument {prev_flag}" if prev_flag else "a positional argument"
        out.append(literal(arg, where, is_id, arg in spec_set))
        i, prev_flag = i + 1, ""
    return out, hits


# ---------------------------------------------------------------- config readers

class TomlSubsetError(ValueError):
    pass


class MiniToml:
    """Reads the TOML client configs use (tables, dotted keys, strings, numbers,
    booleans, arrays, inline tables) on Python versions without tomllib.
    Anything else raises TomlSubsetError and the file is listed as not read."""

    BARE = re.compile(r"[A-Za-z0-9_-]+")
    TOKEN = re.compile(r"[^\s,\]\}#]+")
    NUMBER = re.compile(r"^[+-]?[0-9][0-9_]*(\.[0-9_]+)?([eE][+-]?[0-9_]+)?$")
    DATEISH = re.compile(r"^\d{4}-\d{2}-\d{2}([Tt][0-9:.]+([Zz]|[+-]\d{2}:\d{2})?)?$|^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$")
    ESCAPES = {"b": "\b", "t": "\t", "n": "\n", "f": "\f", "r": "\r", '"': '"', "\\": "\\", "e": "\x1b"}

    def __init__(self, text):
        self.s = text.replace("\r\n", "\n")
        self.i = 0

    def error(self, message):
        raise TomlSubsetError(f"line {self.s.count(chr(10), 0, self.i) + 1}: {message}")

    def peek(self, n=1):
        return self.s[self.i:self.i + n]

    def skip_space(self):
        while self.i < len(self.s) and self.s[self.i] in " \t":
            self.i += 1

    def skip_blank(self):
        while True:
            self.skip_space()
            if self.peek() == "#":
                while self.i < len(self.s) and self.s[self.i] != "\n":
                    self.i += 1
            if self.peek() == "\n":
                self.i += 1
                continue
            return

    def end_line(self):
        self.skip_space()
        if self.peek() == "#":
            while self.i < len(self.s) and self.s[self.i] != "\n":
                self.i += 1
        if self.i < len(self.s):
            if self.s[self.i] != "\n":
                self.error("expected the end of the line")
            self.i += 1

    def parse(self):
        root = {}
        current = root
        while True:
            self.skip_blank()
            if self.i >= len(self.s):
                return root
            if self.peek(2) == "[[":
                self.i += 2
                keys = self.key()
                if self.peek(2) != "]]":
                    self.error("expected ]]")
                self.i += 2
                parent = self.table(root, keys[:-1])
                items = parent.setdefault(keys[-1], [])
                if not isinstance(items, list):
                    self.error("not an array of tables")
                current = {}
                items.append(current)
            elif self.peek() == "[":
                self.i += 1
                keys = self.key()
                if self.peek() != "]":
                    self.error("expected ]")
                self.i += 1
                current = self.table(root, keys)
            else:
                keys = self.key()
                if self.peek() != "=":
                    self.error("expected =")
                self.i += 1
                self.skip_space()
                self.assign(current, keys, self.value())
            self.end_line()

    def table(self, base, keys):
        node = base
        for key in keys:
            nxt = node.setdefault(key, {})
            if isinstance(nxt, list):
                if not nxt:
                    self.error("an empty array of tables")
                nxt = nxt[-1]
            if not isinstance(nxt, dict):
                self.error("not a table")
            node = nxt
        return node

    def assign(self, base, keys, value):
        node = self.table(base, keys[:-1])
        if keys[-1] in node:
            self.error("duplicate key")
        node[keys[-1]] = value

    def key(self):
        keys = []
        while True:
            self.skip_space()
            if self.peek() == '"':
                keys.append(self.basic_string())
            elif self.peek() == "'":
                keys.append(self.literal_string())
            else:
                match = self.BARE.match(self.s, self.i)
                if not match:
                    self.error("expected a key")
                keys.append(match.group(0))
                self.i = match.end()
            self.skip_space()
            if self.peek() != ".":
                return keys
            self.i += 1

    def value(self):
        if self.peek(3) == '"""':
            return self.multiline('"""', escapes=True)
        if self.peek(3) == "'''":
            return self.multiline("'''", escapes=False)
        if self.peek() == '"':
            return self.basic_string()
        if self.peek() == "'":
            return self.literal_string()
        if self.peek() == "[":
            return self.array()
        if self.peek() == "{":
            return self.inline_table()
        match = self.TOKEN.match(self.s, self.i)
        if not match:
            self.error("expected a value")
        token = match.group(0)
        self.i = match.end()
        if token in ("true", "false"):
            return token == "true"
        if self.NUMBER.match(token):
            clean = token.replace("_", "")
            return float(clean) if any(c in clean for c in ".eE") else int(clean)
        if re.match(r"^0[xob][0-9A-Fa-f_]+$", token):
            try:
                return int(token.replace("_", ""), 0)
            except ValueError:
                self.error("unsupported value (not printed)")
        if token in ("inf", "+inf", "-inf", "nan", "+nan", "-nan"):
            return float(token)
        if self.DATEISH.match(token):
            return token
        self.error("unsupported value (not printed)")

    def escape(self):
        code = self.s[self.i + 1:self.i + 2]
        if code in self.ESCAPES:
            self.i += 2
            return self.ESCAPES[code]
        if code in ("u", "U"):
            width = 4 if code == "u" else 8
            digits = self.s[self.i + 2:self.i + 2 + width]
            if not re.fullmatch(r"[0-9A-Fa-f]{%d}" % width, digits):
                self.error("bad unicode escape")
            self.i += 2 + width
            return chr(int(digits, 16))
        self.error("bad escape")

    def basic_string(self):
        self.i += 1
        out = []
        while True:
            if self.i >= len(self.s) or self.s[self.i] == "\n":
                self.error("unterminated string")
            ch = self.s[self.i]
            if ch == '"':
                self.i += 1
                return "".join(out)
            if ch == "\\":
                out.append(self.escape())
                continue
            out.append(ch)
            self.i += 1

    def literal_string(self):
        end = self.s.find("'", self.i + 1)
        newline = self.s.find("\n", self.i + 1)
        if end < 0 or 0 <= newline < end:
            self.error("unterminated string")
        text = self.s[self.i + 1:end]
        self.i = end + 1
        return text

    def multiline(self, quote, escapes):
        self.i += 3
        if self.peek() == "\n":
            self.i += 1
        out = []
        while True:
            if self.i >= len(self.s):
                self.error("unterminated string")
            if self.peek(3) == quote:
                extra = 0
                while extra < 2 and self.s[self.i + 3 + extra:self.i + 4 + extra] == quote[0]:
                    extra += 1
                out.append(quote[0] * extra)
                self.i += 3 + extra
                return "".join(out)
            ch = self.s[self.i]
            if escapes and ch == "\\":
                j = self.i + 1
                while j < len(self.s) and self.s[j] in " \t":
                    j += 1
                if j < len(self.s) and self.s[j] == "\n":
                    while j < len(self.s) and self.s[j] in " \t\n":
                        j += 1
                    self.i = j
                    continue
                out.append(self.escape())
                continue
            out.append(ch)
            self.i += 1

    def array(self):
        self.i += 1
        items = []
        while True:
            self.skip_blank()
            if self.peek() == "]":
                self.i += 1
                return items
            items.append(self.value())
            self.skip_blank()
            if self.peek() == ",":
                self.i += 1
            elif self.peek() == "]":
                self.i += 1
                return items
            else:
                self.error("expected , or ]")

    def inline_table(self):
        self.i += 1
        table = {}
        self.skip_blank()
        if self.peek() == "}":
            self.i += 1
            return table
        while True:
            keys = self.key()
            if self.peek() != "=":
                self.error("expected =")
            self.i += 1
            self.skip_space()
            self.assign(table, keys, self.value())
            self.skip_blank()
            if self.peek() == ",":
                self.i += 1
                self.skip_blank()
            elif self.peek() == "}":
                self.i += 1
                return table
            else:
                self.error("expected , or }")


def load_toml(text):
    if tomllib is not None:
        return tomllib.loads(text)
    return MiniToml(text).parse()


def read_error(exc, kind):
    """Why a config file was not read. TOML parser messages can quote keys and values, so only the line number stays."""
    if isinstance(exc, OSError):
        return exc.strerror or exc.__class__.__name__
    if kind != "codex-toml":
        return f"{exc.__class__.__name__}: {exc}"
    line = re.search(r"line \d+", str(exc))
    where = f" at {line.group(0)}" if line else ""
    hint = "; if the file is valid TOML, run with Python 3.11 or later" if isinstance(exc, TomlSubsetError) else ""
    return f"TOML error{where} (details not printed){hint}"


def strip_jsonc(text):
    """Drop // and /* */ comments and trailing commas outside strings (VS Code allows them)."""
    out, i, n, in_string = [], 0, len(text), False
    while i < n:
        ch = text[i]
        if in_string:
            out.append(ch)
            if ch == "\\" and i + 1 < n:
                out.append(text[i + 1])
                i += 2
                continue
            if ch == '"':
                in_string = False
            i += 1
            continue
        if ch == '"':
            in_string = True
        elif text.startswith("//", i):
            end = text.find("\n", i)
            i = n if end < 0 else end
            continue
        elif text.startswith("/*", i):
            end = text.find("*/", i + 2)
            i = n if end < 0 else end + 2
            continue
        elif ch == ",":
            j = i + 1
            while j < n and text[j] in " \t\r\n":
                j += 1
            if j < n and text[j] in "}]":
                i += 1
                continue
        out.append(ch)
        i += 1
    return "".join(out)


def parse_json_text(text):
    try:
        return json.loads(text)
    except ValueError:
        return json.loads(strip_jsonc(text))


def infer(path, home, client=None, scope=None):
    """Client, scope and format family for a config file path."""
    p = norm(path)
    base = p.rsplit("/", 1)[-1].lower()
    h = norm(home).rstrip("/") if home else ""
    if base == ".claude.json":
        return client or "claude-code", scope or "user and local", "claude-json"
    if base == "managed-mcp.json":
        return client or "claude-code", scope or "managed", "mcpServers"
    if base == "managed-settings.json":
        return client or "claude-code", scope or "managed settings", "claude-settings"
    if base in ("settings.json", "settings.local.json") and "/.claude/" in p:
        if p == h + "/.claude/settings.json":
            return client or "claude-code", scope or "user settings", "claude-settings"
        local = base == "settings.local.json"
        return client or "claude-code", scope or ("project local settings" if local else "project settings"), "claude-settings"
    if base == "claude_desktop_config.json":
        return client or "claude-desktop", scope or "local", "mcpServers"
    if base.endswith(".toml"):
        return client or "codex", scope or ("user" if p == h + "/.codex/config.toml" else "project"), "codex-toml"
    if base == "mcp.json" and "/.cursor/" in p:
        return client or "cursor", scope or ("global" if p == h + "/.cursor/mcp.json" else "project"), "mcpServers"
    if base == "mcp.json" and "/.vscode/" in p:
        return client or "vscode", scope or "workspace", "servers"
    if base == "mcp.json" and "/user/" in p.lower() and "code" in p.lower():
        return client or "vscode", scope or "user", "servers"
    if base == "settings.json" and "/user/" in p.lower() and "code" in p.lower():
        return client or "vscode", scope or "user settings", "vscode-settings"
    if base == "mcp-config.json" and "/.copilot/" in p:
        return client or "vscode", scope or "portable user", "mcpServers"
    if base == ".mcp.json":
        return client or "claude-code", scope or "project", "mcpServers"
    return client or "unknown", scope or "unknown", "mcpServers"


def project_locations(project):
    """The six project-level files, read for --project and, with --all-projects, for every listed repository."""
    join = os.path.join
    return [
        ("claude-code", "project", join(project, ".mcp.json")),
        ("claude-code", "project settings", join(project, ".claude", "settings.json")),
        ("claude-code", "project local settings", join(project, ".claude", "settings.local.json")),
        ("cursor", "project", join(project, ".cursor", "mcp.json")),
        ("vscode", "workspace", join(project, ".vscode", "mcp.json")),
        ("codex", "project", join(project, ".codex", "config.toml")),
    ]


def discover_locations(home, project):
    join = os.path.join
    locations = project_locations(project) + [
        ("claude-code", "user and local", join(home, ".claude.json")),
        ("claude-code", "user settings", join(home, ".claude", "settings.json")),
        ("cursor", "global", join(home, ".cursor", "mcp.json")),
        ("vscode", "portable user", join(home, ".copilot", "mcp-config.json")),
        ("codex", "user", join(home, ".codex", "config.toml")),
    ]
    if sys.platform == "darwin":
        managed = "/Library/Application Support/ClaudeCode"
        desktop = join(home, "Library", "Application Support", "Claude")
        code_user = join(home, "Library", "Application Support", "Code", "User")
    elif os.name == "nt":
        managed = "C:\\Program Files\\ClaudeCode"
        appdata = os.environ.get("APPDATA") or join(home, "AppData", "Roaming")
        desktop = join(appdata, "Claude")
        code_user = join(appdata, "Code", "User")
    else:
        managed = "/etc/claude-code"
        desktop = join(home, ".config", "Claude")
        code_user = join(home, ".config", "Code", "User")
    locations += [
        ("claude-desktop", "local", join(desktop, "claude_desktop_config.json")),
        ("vscode", "user", join(code_user, "mcp.json")),
        ("vscode", "user settings", join(code_user, "settings.json")),
        ("claude-code", "managed", join(managed, "managed-mcp.json")),
        ("claude-code", "managed settings", join(managed, "managed-settings.json")),
    ]
    return locations


def listed_projects(home):
    """Repository paths from ~/.claude.json: (folders on this machine, how many listed folders are gone)."""
    try:
        with open(os.path.join(home, ".claude.json"), encoding="utf-8") as handle:
            data = parse_json_text(handle.read())
    except (OSError, ValueError):
        return [], 0
    paths = [str(p) for p in (data.get("projects") if isinstance(data, dict) and isinstance(data.get("projects"), dict) else {})]
    found = [p for p in paths if os.path.isdir(p)]
    return found, len(paths) - len(found)


def gather(args):
    home = os.path.expanduser("~")
    project = os.path.abspath(os.path.expanduser(args.project)) if args.project else os.getcwd()
    if args.project and not args.fixture and not os.path.isdir(project):
        raise InputError(f"project folder not found: {show_path(project, home)}")
    files, not_read, not_present, seen = [], [], [], set()
    repos = {"read": [], "gone": 0}

    if args.fixture:
        fixture = load_json_file(args.fixture, "fixture")
        if not isinstance(fixture, dict) or not isinstance(fixture.get("files"), list):
            raise InputError(f"{args.fixture}: expected an object with a files list")
        home = fixture.get("home") or "/home/example"
        project = fixture.get("project") or project
        for entry in fixture.get("files", []):
            shown = entry.get("path", "?")
            path = expand_home(shown, home)
            client, scope, kind = infer(path, home, entry.get("client"), entry.get("scope"))
            if entry.get("format") == "toml":
                kind = "codex-toml"
            content = entry.get("content")
            try:
                if kind == "codex-toml":
                    data = load_toml(content or "")
                else:
                    data = content if isinstance(content, dict) else parse_json_text(content or "")
            except ValueError as exc:
                not_read.append(f"{shown}: {read_error(exc, kind)}")
                continue
            if not isinstance(data, dict):
                not_read.append(f"{shown}: top level is not an object")
                continue
            root = expand_home(entry.get("project") or project, home)
            files.append({"client": client, "scope": scope, "kind": kind, "path": path, "shown": shown, "data": data,
                          "project": root})
            if root not in repos["read"]:
                repos["read"].append(root)
        return home, project, files, not_read, not_present, repos

    candidates = []
    if args.discover:
        candidates += [(c, s, p, False, project) for c, s, p in discover_locations(home, project)]
        repos["read"].append(project)
        if args.all_projects:
            listed, repos["gone"] = listed_projects(home)
            for other in listed:
                if norm(other) != norm(project):
                    repos["read"].append(other)
                    candidates += [(c, s, p, False, other) for c, s, p in project_locations(other)]
    candidates += [(None, None, os.path.abspath(os.path.expanduser(p)), True, project) for p in args.paths]
    for client, scope, path, explicit, root in candidates:
        if norm(path) in seen:
            continue
        seen.add(norm(path))
        shown = show_path(path, home)
        if not os.path.isfile(path):
            if explicit:
                raise InputError(f"config file not found: {shown}")
            if root == project:
                not_present.append(shown)
            continue
        client, scope, kind = infer(path, home, client, scope)
        try:
            with open(path, encoding="utf-8") as handle:
                text = handle.read()
            data = load_toml(text) if kind == "codex-toml" else parse_json_text(text)
        except (OSError, ValueError) as exc:
            not_read.append(f"{shown}: {read_error(exc, kind)}")
            continue
        if not isinstance(data, dict):
            not_read.append(f"{shown}: top level is not an object")
            continue
        files.append({"client": client, "scope": scope, "kind": kind, "path": path, "shown": shown, "data": data,
                      "project": root})
    return home, project, files, not_read, not_present, repos


def make_server(name, entry, cf, scope, shown=None, project=None):
    entry = entry if isinstance(entry, dict) else {}
    server = {
        "name": str(name), "client": cf["client"], "scope": scope, "path": cf["path"], "shown": shown or cf["shown"],
        "project": project, "root": project or cf.get("project"), "url": None, "command": None, "args": [], "env": {},
        "headers": {}, "header_refs": [], "type": entry.get("type"), "extra": {}, "wrong_key": None,
    }
    url = entry.get("url") or entry.get("serverUrl")
    if isinstance(url, str) and url.strip():
        server["url"] = url.strip()
    if isinstance(entry.get("command"), str):
        server["command"] = entry["command"]
    if isinstance(entry.get("args"), list):
        server["args"] = [str(a) for a in entry["args"]]
    if isinstance(entry.get("env"), dict):
        server["env"] = {str(k): ("" if v is None else (str(v).lower() if isinstance(v, bool) else str(v)))
                         for k, v in entry["env"].items()}
    headers = entry.get("headers") if isinstance(entry.get("headers"), dict) else entry.get("http_headers")
    if isinstance(headers, dict):
        server["headers"] = {str(k): str(v) for k, v in headers.items()}
    if isinstance(entry.get("env_http_headers"), dict):
        server["header_refs"] = [str(k) for k in entry["env_http_headers"]]
    for key in ("bearer_token_env_var", "enabled_tools", "disabled_tools", "default_tools_approval_mode",
                "enabled", "oauth", "auth", "envFile", "sandboxEnabled", "tools"):
        if key in entry:
            server["extra"][key] = entry[key]
    if server["url"]:
        server["transport"] = "sse" if str(server["type"]).lower() == "sse" else "http"
    elif server["command"]:
        server["transport"] = "stdio"
    else:
        server["transport"] = "unknown"
    return server


def as_dict(value):
    return value if isinstance(value, dict) else {}


def extract_servers(cf, home, project, all_projects):
    data, out = cf["data"], []
    if cf["kind"] == "claude-settings":
        if cf["scope"] == "managed settings":  # Claude Code ignores the key in user, project and local settings
            for name, entry in as_dict(data.get("managedMcpServers")).items():
                out.append(make_server(name, entry, cf, cf["scope"]))
        return out
    if cf["kind"] == "vscode-settings":
        block = as_dict(as_dict(data.get("mcp")).get("servers")) or as_dict(data.get("mcp.servers"))
        for name, entry in block.items():
            out.append(make_server(name, entry, cf, cf["scope"]))
        return out
    if cf["kind"] == "claude-json":
        for name, entry in as_dict(data.get("mcpServers")).items():
            out.append(make_server(name, entry, cf, "user"))
        for project_path, block in as_dict(data.get("projects")).items():
            if isinstance(block, dict) and (all_projects or norm(project_path) == norm(project)):
                shown = f"{cf['shown']} [{show_path(project_path, home)}]"
                for name, entry in as_dict(block.get("mcpServers")).items():
                    out.append(make_server(name, entry, cf, "local", shown=shown, project=project_path))
        return out
    if cf["kind"] == "codex-toml":
        for name, entry in as_dict(data.get("mcp_servers")).items():
            out.append(make_server(name, entry, cf, cf["scope"]))
        return out
    expected = "servers" if cf["kind"] == "servers" else "mcpServers"
    other = "mcpServers" if expected == "servers" else "servers"
    block, wrong = data.get(expected), None
    if not isinstance(block, dict) and isinstance(data.get(other), dict):
        block, wrong = data[other], (other, expected)
    owner = (cf.get("project") or project) if cf["client"] == "claude-code" and cf["scope"] == "project" else None
    for name, entry in as_dict(block).items():
        server = make_server(name, entry, cf, cf["scope"], project=owner)
        server["wrong_key"] = wrong
        out.append(server)
    return out


# ---------------------------------------------------------------- launch commands

def parse_launch(command, args):
    """Package specs behind npx, uvx, pipx run and docker run."""
    base = re.sub(r"\.(exe|cmd|bat)$", "", os.path.basename((command or "").replace("\\", "/"))).lower()
    info = {"launcher": base, "specs": [], "kind": None, "display": base}
    a = list(args)
    if base in ("npx", "bunx", "pnpx"):
        info["kind"] = "npm"
        i = 0
        while i < len(a):
            if a[i] in ("-p", "--package") and i + 1 < len(a):
                info["specs"].append(a[i + 1])
                i += 2
            elif a[i].startswith("--package="):
                info["specs"].append(a[i].split("=", 1)[1])
                i += 1
            elif a[i].startswith("-"):
                i += 1
            else:
                info["specs"].append(a[i])
                break
    elif base == "uvx" or (base == "uv" and a[:2] == ["tool", "run"]):
        info["kind"], info["display"] = "py", "uvx"
        a = a[2:] if base == "uv" else a
        source, first, i = None, None, 0
        while i < len(a):
            if a[i] == "--from" and i + 1 < len(a):
                source, i = a[i + 1], i + 2
            elif a[i].startswith("--from="):
                source, i = a[i].split("=", 1)[1], i + 1
            elif a[i] in ("--with", "--python", "-p", "--index", "--index-url") and i + 1 < len(a):
                i += 2
            elif a[i].startswith("-"):
                i += 1
            else:
                first = a[i]
                break
        info["specs"] = [s for s in (source, first) if s]
        if source:
            info["display"] = "uvx --from"
    elif base == "pipx" and a[:1] == ["run"]:
        info["kind"], info["display"] = "py", "pipx run"
        spec, app, i, rest = None, None, 0, a[1:]
        while i < len(rest):
            if rest[i] == "--spec" and i + 1 < len(rest):
                spec, i = rest[i + 1], i + 2
            elif rest[i].startswith("--spec="):
                spec, i = rest[i].split("=", 1)[1], i + 1
            elif rest[i] in ("--python", "--index-url", "--pip-args") and i + 1 < len(rest):
                i += 2
            elif rest[i].startswith("-"):
                i += 1
            else:
                app = rest[i]
                break
        info["specs"] = [s for s in (spec, app) if s]
        if spec:
            info["display"] = "pipx run --spec"
    elif base == "docker" and "run" in a:
        info["kind"], info["display"] = "docker", "docker run"
        takes_value = {"-e", "--env", "-v", "--volume", "--name", "-p", "--publish", "--network", "--env-file",
                       "-w", "--workdir", "--entrypoint", "--platform", "-u", "--user", "--mount", "-l", "--label"}
        i = a.index("run") + 1
        while i < len(a):
            if a[i] in takes_value:
                i += 2
            elif a[i].startswith("-"):
                i += 1
            else:
                info["specs"].append(a[i])
                break
    return info


def split_npm(spec):
    at = spec.find("@", 1) if spec.startswith("@") else spec.find("@")
    return (spec, "") if at <= 0 else (spec[:at], spec[at + 1:])


def git_ref(spec):
    body = spec.split("://", 1)[1] if "://" in spec else spec
    path = body[body.find("/"):] if "/" in body else ""
    if "#" in path:
        return path.rsplit("#", 1)[1]
    if "@" in path:
        return path.rsplit("@", 1)[1]
    return None


def is_git(spec):
    s = spec.lower()
    return "://" in s or s.startswith(("github:", "git+", "git@"))


def pin_status(info):
    """(pinned, target, why) for a launch, or (None, '', '') when not a package install."""
    if not info["kind"] or not info["specs"]:
        return None, "", ""
    spec = info["specs"][0]
    if is_git(spec) or (info["kind"] == "npm" and "/" in spec and not spec.startswith("@")):
        ref = git_ref(spec) if "://" in spec else (spec.rsplit("#", 1)[1] if "#" in spec else None)
        ok = bool(ref and (COMMIT.match(ref) or EXACT_VERSION.match(ref)))
        return ok, spec, "" if ok else "git source without a commit or version tag"
    if info["kind"] == "npm":
        version = split_npm(spec)[1]
        ok = bool(EXACT_VERSION.match(version))
        return ok, spec, "" if ok else ("@" + version if version else "no version")
    if info["kind"] == "py":
        match = re.match(r"^[A-Za-z0-9_.\-\[\]]+(==|@)(.+)$", spec)
        ok = bool(match and EXACT_VERSION.match(match.group(2)))
        return ok, spec, "" if ok else ("@" + match.group(2) if match else "no version")
    if info["kind"] == "docker":
        if "@sha256:" in spec:
            return True, spec, ""
        last = spec.rsplit("/", 1)[-1]
        tag = last.rsplit(":", 1)[1] if ":" in last else ""
        ok = bool(tag) and tag.lower() != "latest"
        return ok, spec, "" if ok else (":" + tag if tag else "no tag")
    return None, "", ""


def package_names(info):
    names = []
    for spec in info["specs"]:
        s = spec.strip().lower()
        if is_git(s):
            names.append(s)
        elif info["kind"] == "npm":
            names.append(split_npm(s)[0])
        elif info["kind"] == "py":
            names.append(re.split(r"==|@|\[|>=|<=|~=", s, maxsplit=1)[0])
        elif info["kind"] == "docker":
            base = s.split("@", 1)[0]
            names.append(base.rsplit(":", 1)[0] if ":" in base.rsplit("/", 1)[-1] else base)
        else:
            names.append(s)
    return names


def header_args(args):
    """Headers passed on the command line as --header 'Name: value'."""
    found = {}
    for i in range(len(args)):
        value = header_arg(args, i)[0]
        if value and ":" in value:
            name, val = value.split(":", 1)
            found[name.strip()] = val.strip()
    return found


# ---------------------------------------------------------------- registry matching

def host_matches(pattern, host):
    pattern, host = pattern.lower(), (host or "").lower()
    return host.endswith(pattern[1:]) if pattern.startswith("*.") else host == pattern


def package_matches(pattern, names):
    p = pattern.lower()
    for name in names:
        if name == p:
            return True
        if "/" in p and not p.startswith("@"):
            idx = name.find(p)
            while idx >= 0:
                end = idx + len(p)
                if (idx == 0 or name[idx - 1] in "/:@+") and (end == len(name) or name[end] in "/.#@"):
                    return True
                idx = name.find(p, idx + 1)
    return False


def rule_matches(rule, host, path, names):
    matched = False
    if "match_host" in rule:
        if not host_matches(rule["match_host"], host):
            return False
        matched = True
    if "match_path_contains" in rule:
        if rule["match_path_contains"].lower() not in (path or "").lower():
            return False
        matched = True
    if "match_package" in rule:
        if not package_matches(rule["match_package"], names):
            return False
        matched = True
    return matched


def has_keyword(keywords, text):
    hay = (text or "").lower().replace("_", "-")
    for keyword in keywords:
        k = keyword.lower().replace("_", "-")
        if re.search(r"(?<![a-z0-9])" + re.escape(k) + r"(?![a-z0-9])", hay):
            return keyword
    return None


def in_vendor_scope(vendor, names):
    for name in names:
        for scope in vendor.get("vendor_scopes", []):
            s = scope.lower()
            if (s.startswith("@") and name.startswith(s)) or (not s.startswith("@") and s in name):
                return True
    return False


def classify(server, registry):
    host, path, names = server["eff_host"], server["eff_path"], server["pkg_names"]
    vendors = registry["vendors"]
    for group, route in (("official", "official"), ("third_party_known", "third-party"), ("community_known", "community")):
        for vendor in vendors:
            for rule in vendor.get(group, []):
                if rule_matches(rule, host, path, names):
                    return {"vendor": vendor, "route": route, "rule": rule}
    for vendor in vendors:
        for rule in vendor.get("deprecated", []):
            if "match_package" in rule and package_matches(rule["match_package"], names):
                return {"vendor": vendor, "route": "retired", "rule": None, "retired": rule}
    for vendor in vendors:
        if in_vendor_scope(vendor, names):
            return {"vendor": vendor, "route": "vendor-unlisted", "rule": None}
    hay = " ".join([host or "", path or ""] + names)
    for vendor in vendors:
        if has_keyword(vendor.get("keywords", []), hay):
            return {"vendor": vendor, "route": "lookalike" if vendor.get("official") else "community", "rule": None}
    for vendor in vendors:
        if has_keyword(vendor.get("keywords", []), server["name"]):
            return {"vendor": vendor, "route": "named-only", "rule": None}
    return {"vendor": None, "route": "unknown", "rule": None}


def official_list(vendor):
    items = []
    for rule in vendor.get("official", []):
        if rule.get("purpose") == "developer" or rule.get("status"):
            continue  # developer and beta or legacy servers are not the route to recommend
        if rule.get("url"):
            items.append(rule["url"])
        elif rule.get("kind") == "local":
            items.append("the local package " + rule.get("match_package", "?"))
    return ", ".join(dict.fromkeys(items)) or "its own server"


def region_url(vendor, region):
    for rule in vendor.get("official", []):
        if rule.get("region") == region:
            return rule.get("url") or rule.get("match_host")
    return None


# ---------------------------------------------------------------- scan rules

def finding(severity, rule, server, message, scope=None):
    return {
        "severity": severity, "rule": rule, "server": server["name"], "client": server["client"],
        "scope": scope or server["scope"], "path": server["shown"], "message": message,
    }


def read_only_results(server, rule):
    """Evaluate the registry's read_only_checks for the matched rule."""
    query = {}
    if server["eff_url"]:
        for key, value in parse_qsl(urlsplit(server["eff_url"]).query, keep_blank_values=True):
            query[key.lower()] = value
    headers = {k.lower(): v for k, v in server["headers"].items()}
    results = []
    for check in rule.get("read_only_checks", []):
        kind, severity = check.get("type"), check.get("severity", "FAIL")
        key, want = check.get("key", ""), str(check.get("value", ""))
        ok, state = False, ""
        if kind == "url_query":
            got = query.get(key.lower())
            ok = got is not None and got.lower() == want.lower()
            state = f"{key}={echo(got, want)}" if got is not None else f"{key}={want} is not set on the URL"
        elif kind == "url_query_not":
            got = query.get(key.lower())
            ok = got is None or got.lower() != want.lower()
            state = f"{key} is not {want}" if ok else f"{key}={echo(got, want)} is on the URL"
        elif kind == "url_query_or_header":
            got = headers.get(check.get("header", "").lower())
            got = got if got is not None else query.get(key.lower())
            allowed = [v.lower() for v in check.get("values", [])]
            ok = got is not None and (not allowed or got.lower() in allowed)
            if got is None:
                state = f"{key} is not set on the URL or as the {check.get('header')} header"
            elif not ok:
                state = f"{key}={echo(got)}, not {' or '.join(check.get('values', []))}"
            else:
                state = f"{key}={echo(got)}"
        elif kind == "env":
            got = server["env"].get(key)
            if got is not None and is_reference(got):
                ok, severity, state = False, "WARN", f"env {key} is set by a reference the scanner cannot resolve"
            else:
                ok = got is not None and got.strip().lower() == want.lower()
                state = f"env {key}={echo(got, want)}" if got is not None else f"env {key}={want} is not set"
        elif kind == "env_set":
            ok = bool(server["env"].get(key))
            state = f"env {key} is set{check.get('unverified', '')}" if ok else f"env {key} is not set"
        elif kind == "env_off":
            got = server["env"].get(key)
            if got is not None and is_reference(got):
                ok, severity, state = False, "WARN", f"env {key} is set by a reference the scanner cannot resolve"
            else:
                ok = got is None or got.strip().lower() in ("", "0", "false", "no", "off")
                state = f"env {key} is off" if ok else f"env {key} is on"
        elif kind == "arg_absent":
            ok = not any(arg == want or arg.startswith(want + "=") for arg in server["args"])
            state = f"started without {want}" if ok else f"started with {want}"
        elif kind == "path_contains":
            path = server["eff_path"] or ""
            bad = [x for x in check.get("fail_if_contains", []) if x in path]
            ok = want in path and not bad
            if bad:
                severity, state = "FAIL", f"the URL path contains {bad[0]}"
            else:
                state = f"the URL path contains {want}" if ok else f"the URL path does not contain {want}"
        enforces = check.get("enforces", check.get("severity", "FAIL") == "FAIL")
        results.append({"ok": ok, "severity": severity, "state": state, "why": check.get("why", ""),
                        "enforces": bool(enforces)})
    return results


def describe_where(server, host, home):
    if host:
        return host
    names = server["pkg_names"]
    if names:
        name = names[-1] if server["launch"]["kind"] == "py" and len(names) > 1 else names[0]
        return "package " + safe_text(name)
    if server["command"]:
        first = next((a for a in server["display_args"] if not a.startswith("-")), "")
        return " ".join(x for x in (display_command(server["command"], home), first) if x)
    return "an entry with no url or command"


def approval_note(server):
    notes = []
    mode = server["extra"].get("default_tools_approval_mode")
    if mode:
        notes.append(f"approval mode {mode if mode in APPROVAL_MODES else '[redacted]'}")
    tools = server["extra"].get("enabled_tools")
    if isinstance(tools, list):
        notes.append(f"enabled_tools allowlist of {len(tools)}")
    return ("; " + "; ".join(notes)) if notes else ""


def analyse(server, registry, expect, as_of, home, project, out):
    def add(severity, rule, message):
        out.append(finding(severity, rule, server, message))

    info = parse_launch(server["command"], server["args"]) if server["command"] else \
        {"launcher": "", "specs": [], "kind": None, "display": ""}
    wrapper = None
    for wrap in registry.get("wrappers", []):
        if package_matches(wrap["match_package"], package_names(info)):
            wrapper = wrap
    for name, value in header_args(server["args"]).items():
        server["headers"].setdefault(name, value)
    wrapped_url = next((a for a in server["args"] if re.match(r"^https?://", a, re.I)), None) if wrapper else None
    url = server["url"] or wrapped_url
    start = URL_START.match(url or "")
    user_info, after_user = split_userinfo(url[start.end():]) if start else (USER_PASS_AT.match(url or "") and url, "")
    try:
        parts = urlsplit(url[:start.end()] + after_user if start else url) if url else None
        host = (parts.hostname or "").lower() if parts else ""
    except ValueError:
        parts, host = None, ""
    display_args, arg_hits = walk_args(server["args"], home, info["specs"], url_arg=wrapped_url)
    if server["command"] and re.search(r"\s", server["command"].strip()) and not command_is_path(server["command"]):
        arg_hits += shell_hits(server["command"], home)
    server.update(eff_url=url, eff_host=host, eff_path=parts.path if parts else "", display_args=display_args,
                  pkg_names=[] if wrapper else package_names(info), launch=info, wrapper=wrapper)
    cls = classify(server, registry)
    vendor, rule, route = cls["vendor"], cls["rule"] or {}, cls["route"]
    vname = vendor["name"] if vendor else ""
    where = describe_where(server, host, home)
    server["route"] = {"route": route, "vendor": vname, "kind": rule.get("kind", "")}
    developer = rule.get("purpose") == "developer"

    # S01 route
    if route == "official":
        extra = " behind a proxy wrapper" if wrapper else ""
        status = f", {rule['status']}" if rule.get("status") else ""
        note = f": {rule['note']}" if rule.get("note") else ""
        add("PASS", "S01", f"official {rule.get('kind', '')} {vname} server ({where}){extra}{status}{note}.".replace("  ", " "))
    elif route == "third-party":
        add("WARN", "S01", f"third-party server published by {rule.get('publisher', 'a third party')}, not by "
                           f"{vname}: {rule.get('note', '')}. Run the publisher review before production data.")
    elif route == "community" and rule:
        note = f" ({rule['note']})" if rule.get("note") else ""
        add("WARN", "S01", f"community server{note}; run the publisher review in "
                           f"references/choosing-servers.md before production data.")
    elif route == "community":
        add("WARN", "S01", f"community server for {vname}, which publishes no MCP server of its own; run the "
                           f"publisher review, or use the export route (references/choosing-servers.md).")
    elif route == "vendor-unlisted":
        add("WARN", "S01", f"{where} sits in {vname}'s own namespace but is not in the registry. Verify it on "
                           f"{vname}'s MCP page, then add it to assets/server-registry.json with the date checked.")
    elif route == "lookalike":
        add("FAIL", "S01", f"{where} is not a {vname} endpoint, and {vname} publishes {official_list(vendor)}. "
                           f"Treat it as a community server: replace it with the official one, or record a "
                           f"publisher review and why the official server cannot do the job.")
    elif route == "retired":
        publisher = vname if cls["retired"].get("publisher", "vendor") == "vendor" else "a community maintainer"
        add("INFO", "S01", f"{where} was published by {publisher} and is retired; S02 names the replacement.")
    elif route == "named-only":
        add("WARN", "S01", f"named after {vname}, but {where} is not one of its endpoints "
                           f"({official_list(vendor)}); treat it as a community server until a publisher review passes.")
    else:
        add("WARN", "S01", f"{where} is not in the registry. Check it against the vendor's MCP page; if it is your "
                           f"own deployment of a vendor server, record the repository and commit. Run the publisher review.")

    # S02 retired, deprecated or legacy
    names = server["pkg_names"]
    for candidate in registry["vendors"]:
        for dep in candidate.get("deprecated", []):
            scoped = "match_host" not in dep and "match_package" not in dep
            if scoped and (not vendor or vendor["id"] != candidate["id"]):
                continue
            if not rule_matches(dep, host, server["eff_path"], names):
                continue
            what = f"the package {dep['match_package']}" if "match_package" in dep else \
                f"the {dep.get('match_path_contains', host)} endpoint"
            eol = dep.get("eol")
            if dep.get("severity") == "WARN" and not eol:
                add("WARN", "S02", f"{what} is {dep.get('status', 'a legacy server')}; move to {dep['replacement']}.")
            elif eol and as_of < parse_date(eol, "registry eol"):
                add("WARN", "S02", f"{what} reaches end of life on {eol}; move to {dep['replacement']}.")
            elif eol:
                add("FAIL", "S02", f"{what} reached end of life on {eol}; move to {dep['replacement']}, then "
                                   f"remove this entry and revoke its credential.")
            else:
                add("FAIL", "S02", f"{what} is retired or deprecated; move to {dep['replacement']}, then remove "
                                   f"this entry and revoke its credential.")

    # S03 inline secrets
    for key, value in server["env"].items():
        if env_secret(key, value):
            add("FAIL", "S03", f"env {key} has an inline value (value not printed). Use OAuth, an environment "
                               f"or input reference instead, and have its owner rotate it.")
    for key, value in server["headers"].items():
        if header_secret(key, value):
            add("FAIL", "S03", f"header {key} has an inline value (value not printed). Use OAuth or a "
                               f"reference such as ${{VAR}}, and have its owner rotate it.")
    for field in ("auth", "oauth"):
        for key, value in as_dict(server["extra"].get(field)).items():
            if isinstance(value, str) and re.search(r"secret|password|token", key, re.I) and env_secret(key, value):
                add("FAIL", "S03", f"{field} field {key} has an inline value (value not printed). Use a reference or a "
                                   f"public client, keep the file private and out of version control, and have its "
                                   f"owner rotate it if it was shared.")
    for hit in dict.fromkeys(arg_hits):
        if hit == "a positional argument":
            add("FAIL", "S03", "a positional argument looks like a key or token (value not printed). Move it to an "
                               "environment or input reference, and have its owner rotate it.")
        else:
            add("FAIL", "S03", f"{hit} has an inline value (value not printed). Move it to an environment "
                               f"reference and have its owner rotate it.")

    # S04 credential in URL
    if url and parts:
        if user_info:
            add("FAIL", "S04", "the URL carries user credentials (not printed); move them to OAuth or a header reference.")
        if url_credential(url) == "a connection parameter":
            add("FAIL", "S04", "the URL carries a password or token parameter (not printed); move it to OAuth or a "
                               "header reference and rotate it.")
        for key, value in parse_qsl(parts.query, keep_blank_values=True):
            if not value:
                continue
            if key.lower() in SENSITIVE_QUERY or SENSITIVE.search(key):
                if is_reference(value):
                    add("WARN", "S04", f"URL query key {key} takes a credential from a reference; the expanded URL "
                                       f"still holds it and URLs are often logged. Prefer a header or OAuth.")
                else:
                    add("FAIL", "S04", f"URL query key {key} carries a credential (value not printed). Treat the "
                                       f"whole URL as a secret: move the key to a header reference or OAuth and rotate it.")
            elif secretish(value):
                add("WARN", "S04", f"URL query key {key} carries a value that looks like a key or token (not printed). "
                                   f"Treat the whole URL as a secret and keep it out of shared files.")
        if any(secretish(seg) for seg in parts.path.split("/")):
            add("WARN", "S04", "the URL path holds a long random segment (not printed) that may be a key, as some "
                               "hosted endpoints embed one. Treat the whole URL as a secret and keep it out of shared files.")

    # S05 unpinned install (a proxy wrapper is reported once, under S13)
    pinned, target, why = pin_status(info)
    if pinned is False and not wrapper and route != "retired":
        official = route == "official"
        add("WARN" if official else "FAIL", "S05",
            f"{info['display']} {safe_text(target)} is not pinned ({why}). Pin an exact version or a reviewed commit"
            f"{'' if official else ' before the publisher review can pass'}.")

    # S06 read-only setting. Enforcing checks make the connection read-only;
    # advisory checks (WARN by default) can fail without making it writable.
    effective_write = bool(rule.get("write_capable"))
    config_enforced, missing, present = False, [], []
    if rule and not developer:
        results = read_only_results(server, rule)
        enforcing = [r for r in results if r["enforces"]]
        failed = [r for r in results if not r["ok"]]
        for result in failed:
            add(result["severity"], "S06", f"{result['state']}: {result['why']}." if result["why"] else result["state"] + ".")
            missing.append(result["state"])
        present = [r["state"] for r in results if r["ok"]]
        config_enforced = bool(enforcing) and all(r["ok"] for r in enforcing)
        if any(not r["ok"] for r in enforcing):
            effective_write = True
        layer = rule.get("read_only_layer") or vendor.get("read_only_layer", "the source permissions")
        if config_enforced and not failed:
            tail = "; official read-only server" if route == "official" and not rule.get("write_capable") else ""
            review = "; publisher review still required" if route != "official" else ""
            add("PASS", "S06", f"read-only settings present: {', '.join(present)}{tail}{review}{approval_note(server)}.")
        elif not enforcing and route == "official" and not rule.get("write_capable"):
            config_enforced = True
            present = ["the server has no write tools"]
            note = f" Note: {vendor['note']}" if vendor.get("note") else ""
            add("PASS", "S06", f"official read-only server{approval_note(server)}.{note}")
        elif not enforcing and rule.get("write_capable"):
            add("INFO", "S06", f"no config-level read-only switch; read-only depends on {layer}. Prove it with T2 and T4.")
    server["read_only"] = {"effective_write": effective_write, "config_enforced": config_enforced,
                           "present": present, "missing": missing, "developer": developer}

    # S07 region
    if vendor and route == "official" and vendor["id"] in expect:
        wanted, region = expect[vendor["id"]], rule.get("region")
        if region == "auto":
            add("INFO", "S07", f"this endpoint routes to the account's region; confirm the {wanted.upper()} project in T1.")
        elif region and region != wanted:
            good = region_url(vendor, wanted)
            advice = f" Use {good}." if good else f" {vname} lists no {wanted.upper()} endpoint in the registry; check its MCP page."
            add("FAIL", "S07", f"{host} is the {region.upper()} endpoint, but stack.md expects {wanted.upper()}.{advice}")

    # S08 transport
    if parts and parts.scheme in ("http", "ws") and host not in LOCAL_HOSTS and not host.endswith(".localhost"):
        add("FAIL", "S08", f"plain {parts.scheme}:// to {host}: tokens and results travel unencrypted. Use the "
                           f"encrypted endpoint.")
    sse = server["transport"] == "sse" or (server["eff_path"] or "").rstrip("/").endswith("/sse")
    if sse and route == "official" and not any(f["rule"] == "S02" and f["server"] == server["name"] and
                                                 f["path"] == server["shown"] for f in out):
        add("WARN", "S08", f"SSE transport; {vname} serves Streamable HTTP. Move to its /mcp endpoint.")

    # S09 wrong top-level key
    if server["wrong_key"]:
        used, wanted = server["wrong_key"]
        reader = "VS Code" if wanted == "servers" else "this client"
        tail = "/".join(server["shown"].split("/")[-2:])
        add("FAIL", "S09", f"{tail} uses {used}; {reader} reads {wanted}, so this server never loads. "
                           f"Rename the top-level key.")

    # S11 write-capable server in a shared file
    shared = server["scope"] in ("project", "workspace")
    if (shared and route in ("official", "third-party", "community") and rule and effective_write
            and not config_enforced and not developer):
        add("WARN", "S11", "write-capable server in a shared project file: every teammate who opens the project "
                           "gets it, and some clients load project servers without a prompt in non-interactive "
                           "runs. Keep it in personal scope, or enforce read-only first (step 6).")

    # S12 credential file inside the project
    root = server.get("root") or project

    def check_path(label, value):
        expanded = expand_home(value, home)
        absolute = expanded.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", expanded) is not None
        if not absolute:
            add("FAIL", "S12", f"{label} points inside the project ({value}, relative to the working directory). "
                               f"Keep credential files outside repositories with owner-only permissions.")
        elif inside(expanded, root):
            add("FAIL", "S12", f"{label} points inside the project ({show_path(expanded, home)}). Move the file "
                               f"outside the repository with owner-only permissions, and rotate it if it was committed.")

    for key, value in server["env"].items():
        if value and not is_reference(value) and CREDENTIAL_PATH_KEY.match(key) and looks_like_path(value):
            check_path(key, value)
    for i, arg in enumerate(server["args"]):
        name, value = None, None
        if arg.startswith("-") and "=" in arg:
            name, value = arg.split("=", 1)
        elif arg.startswith("-") and i + 1 < len(server["args"]):
            name, value = arg, server["args"][i + 1]
        if name and CREDENTIAL_PATH_ARG.match(name) and value and not is_reference(value) and looks_like_path(value):
            check_path(name, value)

    env_file = server["extra"].get("envFile")
    if isinstance(env_file, str) and env_file.strip():
        expanded = expand_home(env_file, home)
        absolute = expanded.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", expanded) is not None
        if "${workspace" in env_file or not absolute or inside(expanded, root):
            add("WARN", "S12", "envFile points inside the project. The scanner does not open it: keep env files that "
                               "hold credentials outside repositories, or confirm the file is ignored by version control.")

    # S13 proxy wrapper
    if wrapper:
        pinned_wrap, target_wrap, _ = pin_status(info)
        version = version_key(str(target_wrap).rsplit("@", 1)[-1]) if pinned_wrap else None
        low, fixed = version_key(wrapper.get("affected_from", "")), version_key(wrapper.get("fixed", ""))
        if version and fixed and (not low or version >= low) and version < fixed:
            add("FAIL", "S13", f"proxy wrapper {safe_text(target_wrap)} is affected by {wrapper['advisory']}, fixed in "
                               f"{wrapper['fixed']}. Upgrade the pin, or use the client's native url entry.")
        elif pinned_wrap:
            add("INFO", "S13", f"proxy wrapper {safe_text(target_wrap)}: {wrapper['note']}.")
        else:
            add("WARN", "S13", f"proxy wrapper {safe_text(target_wrap) or wrapper['match_package']} (unpinned): {wrapper['note']}.")

    # S15 settings that let tool calls run without a person
    if server["extra"].get("sandboxEnabled") is True:
        add("WARN", "S15", "sandboxEnabled: VS Code auto-approves this server's tool confirmations. Keep it off for a "
                           "server with write tools.")
    mode = server["extra"].get("default_tools_approval_mode")
    if mode in SKIPS_APPROVAL:
        add("WARN", "S15", f"default_tools_approval_mode {mode}: tools on this server may run without a prompt. Use "
                           f"writes or prompt, and check the value's meaning in the client docs.")
    per_tool = as_dict(server["extra"].get("tools"))
    skipped = sorted(k for k, v in per_tool.items() if as_dict(v).get("approval_mode") in SKIPS_APPROVAL)
    if skipped:
        add("WARN", "S15", f"tools.<tool>.approval_mode skips the prompt for {', '.join(safe_text(k) for k in skipped)}. "
                           f"Never remember an approval for a write tool.")


def shadowing(servers, out):
    groups = {}
    for server in servers:
        groups.setdefault((server["client"], server["name"]), []).append(server)
    for (client, name), items in groups.items():
        scopes = list(dict.fromkeys(s["scope"] for s in items))
        if len(scopes) < 2:
            continue
        if client == "claude-code" and "managed" in scopes:
            winner = next(s for s in items if s["scope"] == "managed")
            out.append(finding("WARN", "S10", winner, "defined in managed-mcp.json and elsewhere; the managed file takes "
                                                      "exclusive control, so the other definitions do not load.",
                               scope=", ".join(scopes)))
            continue
        if client == "claude-code" and "managed settings" in scopes:
            winner = next(s for s in items if s["scope"] == "managed settings")
            out.append(finding("WARN", "S10", winner, "defined in managedMcpServers and elsewhere; the managed entry takes "
                                                      "precedence over the same name in local, project or user scope.",
                               scope=", ".join(scopes)))
            continue
        if client == "claude-code" and all(s in CLAUDE_ORDER for s in scopes):
            users = [s for s in items if s["scope"] == "user"]
            contexts = {}
            for s in items:
                if s["scope"] in ("local", "project"):
                    contexts.setdefault(norm(s.get("project") or ""), []).append(s)
            for entries in contexts.values():
                stack = sorted(entries + users, key=lambda x: CLAUDE_ORDER.index(x["scope"]))
                order = list(dict.fromkeys(s["scope"] for s in stack))
                if len(order) < 2:
                    continue
                listing = ", ".join(f"{s['scope']} ({s['shown']})" for s in stack)
                if order[1] == "project":
                    tail = ("The local entry wins for this user only and fields are not merged; teammates and runs "
                            "without it get the project entry.")
                    if "user" in order:
                        tail += " The user entry applies in your other projects."
                else:
                    tail = (f"The {order[0]} entry wins inside this project and fields are not merged; the user entry "
                            f"applies in your other projects.")
                out.append(finding("WARN", "S10", stack[0], f"defined in {len(order)} scopes: {listing}. {tail} "
                                                            f"Keep one definition, read-only in every scope that stays.",
                                   scope=", ".join(order)))
            continue
        out.append(finding("WARN", "S10", items[0], f"defined in {', '.join(scopes)}; confirm in the client which "
                                                    f"definition is active and remove the other.", scope=", ".join(scopes)))
    managed = [s for s in servers if s["client"] == "claude-code" and s["scope"] == "managed"]
    if managed and any(s["client"] == "claude-code" and s["scope"] not in ("managed", "managed settings") for s in servers):
        out.append(finding("INFO", "S10", managed[0], "managed-mcp.json is present: it takes exclusive control of "
                                                     "the terminal agent's MCP servers, so other scopes do not load."))


REACH = {
    "user settings": "every repository on this machine",
    "managed settings": "every machine the policy reaches",
    "project settings": "everyone who trusts this folder, once the file is committed",
    "project local settings": "you, in this project",
}


def settings_findings(files, out):
    """S14 approval keys that skip the .mcp.json prompt; S15 modes and allow rules that skip a person."""
    for cf in files:
        if cf["kind"] != "claude-settings":
            continue
        data, reach = cf["data"], REACH.get(cf["scope"], "the sessions that read this file")

        def add(severity, key, message, rule="S14"):
            out.append(finding(severity, rule, {"name": key, "client": cf["client"], "scope": cf["scope"],
                                                "shown": cf["shown"]}, message))

        if data.get("enableAllProjectMcpServers") is True:
            add("WARN", "enableAllProjectMcpServers", f"true: .mcp.json servers load without the approval prompt for "
                                                      f"{reach}. Remove it where marketing servers live and approve "
                                                      f"servers one at a time.")
        enabled = [str(x) for x in data.get("enabledMcpjsonServers") or [] if isinstance(x, str)]
        if enabled:
            add("WARN", "enabledMcpjsonServers", f"pre-approves {', '.join(enabled)} for {reach}. Keep only servers "
                                                 f"that are read-only by an enforced layer.")
        disabled = [str(x) for x in data.get("disabledMcpjsonServers") or [] if isinstance(x, str)]
        if disabled:
            add("INFO", "disabledMcpjsonServers", f"rejects {', '.join(disabled)} in every session type.")
        permissions = as_dict(data.get("permissions"))
        if permissions.get("defaultMode") in ("auto", "bypassPermissions"):
            add("WARN", "permissions.defaultMode", f"{permissions['defaultMode']}: MCP tool calls can run without a "
                                                   f"person approving each one. Set default in user or managed "
                                                   f"settings for sessions with marketing servers.", rule="S15")
        for item in permissions.get("allow") or []:
            if not isinstance(item, str) or not item.startswith("mcp__"):
                continue
            server_name, _, tool = item[5:].partition("__")
            if server_name and (not tool or tool == "*"):
                add("WARN", "permissions.allow", f"{safe_text(item)} allows every tool on the server, write tools and "
                                                 f"tools added later included. Allow read tools by name.", rule="S15")
            elif WRITE_TOOL.match(tool):
                add("WARN", "permissions.allow", f"{safe_text(item)} allows a tool whose name reads as a write. Keep "
                                                 f"it Needs approval or Blocked.", rule="S15")


def inventory_line(server, home):
    if server["eff_url"] and not server["wrapper"]:
        transport = f"{server['transport']} {redact_url(server['eff_url'])}"
    elif server["command"]:
        transport = "stdio " + " ".join([display_command(server["command"], home)] + server["display_args"])
    else:
        transport = "no url or command (the client cannot start it)"
    bits = [f"{server['client']} {server['scope']} {server['shown']}", transport]
    if server["env"]:
        bits.append("env " + ", ".join(server["env"].keys()))
    names = list(server["headers"].keys()) + server["header_refs"]
    if names:
        bits.append("headers " + ", ".join(names))
    route = server["route"]
    label = route["route"] + (f" {route['kind']}" if route["kind"] else "") + (f" ({route['vendor']})" if route["vendor"] else "")
    bits.append("route " + label)
    ro = server.get("read_only", {})
    known = route["route"] in ("official", "third-party", "community") and route["vendor"]
    if ro.get("developer"):
        evidence = "not applicable (developer server, keep it out of marketing profiles)"
    elif ro.get("missing"):
        evidence = "missing: " + ", ".join(ro["missing"])
    elif ro.get("config_enforced"):
        evidence = ", ".join(ro.get("present") or ["enforced"])
    elif known and ro.get("effective_write"):
        evidence = "none in config; prove the source-side layer with T2 and T4"
    else:
        evidence = "unknown; list the tools in the client (T2)"
    bits.append("read-only evidence: " + evidence)
    if server["wrong_key"]:
        bits.append("never loads (wrong top-level key)")
    if server["extra"].get("enabled") is False:
        bits.append("disabled")
    return f"- {server['name']}: " + "; ".join(bits)


def scrub(text):
    """The last filter on every line of output, whichever message built it."""
    text = OUT_USERINFO.sub(r"\1[redacted]@", text)
    text = OUT_BEARER.sub(r"\1 [redacted]", text)
    return OUT_PAIR.sub(r"\1[redacted]", text)


def parse_expect(text, registry):
    ids = {v["id"] for v in registry["vendors"]}
    expect = {}
    for part in [p.strip() for p in (text or "").split(",") if p.strip()]:
        if "=" not in part:
            raise InputError(f"--expect-region takes vendor=region pairs, got {part!r}")
        vendor, region = [x.strip().lower() for x in part.split("=", 1)]
        if vendor not in ids:
            raise InputError(f"unknown vendor {vendor!r} in --expect-region; use one of: {', '.join(sorted(ids))}")
        expect[vendor] = region
    return expect


def scan(args):
    if args.fixture and (args.discover or args.paths):
        raise InputError("use --fixture on its own, without --discover or config paths")
    if not (args.fixture or args.discover or args.paths):
        raise InputError("pass config paths, --discover or --fixture")
    registry = load_json_file(args.registry, "registry")
    if not isinstance(registry.get("vendors"), list):
        raise InputError("the registry has no vendors list")
    as_of = parse_date(args.as_of, "--as-of") if args.as_of else dt.date.today()
    expect = parse_expect(args.expect_region, registry)
    home, project, files, not_read, not_present, repos = gather(args)
    servers = []
    for cf in files:
        servers.extend(extract_servers(cf, home, project, args.all_projects))
    findings = []
    for server in servers:
        analyse(server, registry, expect, as_of, home, project, findings)
    shadowing(servers, findings)
    settings_findings(files, findings)
    findings.sort(key=lambda f: ORDER[f["severity"]])
    counts = {k: sum(1 for f in findings if f["severity"] == k) for k in ORDER}

    listed = [show_path(r, home) for r in repos["read"]] if args.all_projects else []
    if args.json:
        print(scrub(json.dumps({
            "as_of": as_of.isoformat(), "project": show_path(project, home), "repositories": listed,
            "repositories_gone": repos["gone"],
            "files": [{"client": f["client"], "scope": f["scope"], "path": f["shown"]} for f in files],
            "servers": [{"name": s["name"], "client": s["client"], "scope": s["scope"], "path": s["shown"],
                         "project": show_path(s["project"], home) if s.get("project") else None,
                         "transport": s["transport"], "url": redact_url(s["eff_url"]) if s["eff_url"] else None,
                         "args": s["display_args"], "env_keys": list(s["env"].keys()),
                         "header_names": list(s["headers"].keys()) + s["header_refs"], "route": s["route"],
                         "read_only": s.get("read_only", {})} for s in servers],
            "findings": findings, "not_read": not_read, "not_present": not_present,
            "summary": counts,
        }, indent=2)))
    else:
        lines = [f"MCP config scan as of {as_of.isoformat()}. Project {show_path(project, home)}. Read {plural(len(files), 'file')}, "
                 f"{plural(len(servers), 'server')}. Nothing was changed; secret values are never printed."]
        lines.append("\nFindings")
        lines += [f"{f['severity']} {f['rule']} {f['server']} ({f['client']} {f['scope']} {f['path']}): {f['message']}" for f in findings]
        if not findings:
            lines.append("- none")
        lines.append("\nInventory")
        lines += [inventory_line(server, home) for server in servers]
        if not servers:
            lines.append("- no servers found")
        lines.append("\nNot read")
        lines += [f"- {item}" for item in not_read or ["none"]]
        if not_present:
            lines.append(f"\nChecked and not present: {len(not_present)} locations")
            lines += [f"- {item}" for item in not_present]
        if listed:
            lines.append(f"\nRepositories read: {len(listed)}")
            lines += [f"- {item}" for item in listed]
            if repos["gone"]:
                lines.append(f"- {plural(repos['gone'], 'more listed folder')} in ~/.claude.json no longer on this machine")
        lines.append("\nNot in these files: chat-app connectors, ChatGPT plugins, org-managed connectors, the terminal "
                     "agent's plugin servers and desktop extensions. List them from /mcp in the terminal agent and from "
                     "each app's connector and extension settings (references/clients.md).")
        if not_read:
            lines.append("\nSome files were not read, so this scan is incomplete.")
        lines.append(f"\nSummary: {counts['FAIL']} FAIL, {counts['WARN']} WARN, {counts['INFO']} INFO, {counts['PASS']} PASS")
        print(scrub("\n".join(lines)))
    return 1 if counts["FAIL"] else (2 if not_read else 0)


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("paths", nargs="*", help="config files to read")
    parser.add_argument("--discover", action="store_true", help="read the known config locations")
    parser.add_argument("--project", help="repository whose project files to read (default: current directory)")
    parser.add_argument("--all-projects", action="store_true", help="with --discover, also read every repository listed in ~/.claude.json")
    parser.add_argument("--fixture", help="fixture JSON with virtual config files")
    parser.add_argument("--registry", default=DEFAULT_REGISTRY, help="server registry JSON")
    parser.add_argument("--expect-region", default="", help="vendor=region pairs, for example mixpanel=eu")
    parser.add_argument("--as-of", help="date for end-of-life checks, YYYY-MM-DD (default: today)")
    parser.add_argument("--json", action="store_true")
    argv = list(sys.argv[1:] if argv is None else argv)
    if argv[:1] == ["scan"]:
        argv = argv[1:]  # accept the older "mcp_inventory.py scan ..." form
    args = parser.parse_args(argv)
    try:
        return scan(args)
    except InputError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())
