Skip to content

v3.0.0

Latest

Choose a tag to compare

@github-actions github-actions released this 09 Oct 20:10
· 7 commits to main since this release
f9f4bd0

Upgrade notes

Things 2.7.3 accepted that 3.0.0 refuses, and defaults or output that changed. This is a major release because of them: upgrading can stop a project's servers from loading until you approve its files (first item below).
Each is described in full in the section named at the end of the line.
MIGRATING.md covers each one: how to tell whether you are affected, what
to do, how to verify it, and how to roll back to 2.7.3.

  • Project files need approval. A repository's .pmcp/manifest.yaml, .mcp.json
    and .mcp-gateway-policy.yaml are ignored (with a WARNING naming the command) until
    you run pmcp trust approve <absolute path>; editing an approved file revokes it. A
    project policy can now only narrow ~/.claude/gateway-policy.yaml, never replace it.
    User-scoped files and explicit --config/--policy paths are unaffected. Security
  • PMCP_MANIFEST_PATH, PMCP_CONFIG and PMCP_POLICY must be exported in your
    shell.
    A value that came from .env, .env.pmcp or ~/.config/pmcp/pmcp.env is
    ignored as if unset. Security
  • Spawned servers no longer inherit the keys pmcp loaded from .env, except a
    server's own declared env_var. Shell-exported variables are still inherited. An
    install spawn under pmcp --project <dir> run from another directory no longer
    inherits a credential stored in <dir>/.env.pmcp. Security
  • A symlinked project .env.pmcp is refused. pmcp no longer follows a symlink in a
    project's credential store at all, wherever it points. pmcp secrets set, pmcp secrets sync --to-scope project and gateway.auth_connect with scope="project"
    report refusing to write .env.pmcp: it is a symlink and write nothing; 2.7.3 wrote the
    secrets wherever the link pointed, which a cloned repository chooses. Every pmcp
    command also stops LOADING such a store at startup: run from that directory, it prints
    pmcp: refusing to load .env.pmcp: it is a symlink on stderr and goes on without those
    keys, where 2.7.3 loaded whatever the link pointed at — this includes the gateway. Keep
    linked credentials in the user store ~/.config/pmcp/pmcp.env, which still follows its
    link. A project .env.pmcp
    that is not a regular file (a fifo, a socket, a device) is refused rather than read (at
    startup: skipped with a warning), and any other failure to read or write the store (a
    directory at the path, no permission, bytes that are not UTF-8, a value with a newline,
    a --project path the system cannot resolve) is reported the same way instead of
    crashing the command. A source store that pmcp secrets sync only reads is reported as
    refusing to read …. Every other reader of a
    repository-controlled store reads it the same confined way — remote-header
    ${VAR} resolution, a tenant .pmcp/tenants/<id>/pmcp.env, the gateway's
    credential check, spawn-time env stripping, pmcp secrets check, and the .env
    pmcp finds at startup by walking up from where it is installed (inside a checkout
    when it runs from that checkout's .venv) — and treats a refused store as empty,
    printing pmcp: refusing to read .env.pmcp: … once per store per configuration
    load; the feedback gate refuses to submit instead. A tenant id made only of dots
    (., ..) is refused. A store behind a directory pmcp cannot search counts as
    unreadable, not absent: a reader that only looks warns and reads it as empty (the
    user store too, so pmcp still starts), and one that decides — the feedback gate, a
    rewrite — refuses. Security
  • A project file supplies credentials only. A checkout's .env.pmcp, the .env
    in the directory the gateway runs from (or that pmcp finds when it is installed in a
    checkout's .venv -- any .env its startup walk finds outside your home directory and
    its ancestors), and a tenant pmcp.env no longer put anything
    into pmcp's own environment. Their values are credentials: a server's declared
    credential, a remote ${VAR} header, the credential checks and the pmcp secrets
    commands still find them. Nothing else does — not pmcp's own settings
    (PMCP_LOG_LEVEL, PMCP_PORT, …), not HOME, a proxy or a CA bundle, and not a
    child process pmcp starts. Such a variable prints pmcp: Ignoring <VAR> in .env.pmcp: a project file supplies credentials only, … and is left unset; in 2.7.3 it applied.
    Any other non-credential variable in such a file is ignored without a line. A value
    is expanded only from keys defined earlier in the same file: LEAK=${GITHUB_TOKEN},
    which 2.7.3 filled from your environment, is ignored with pmcp: Ignoring LEAK in .env.pmcp: its value refers to a variable the file does not define, …. Your shell
    and ~/.config/pmcp/pmcp.env now win over a project file for every lookup, and every
    reader -- runtime and diagnostics alike -- goes through the same lookup, which reads a
    project's own .env before its .env.pmcp, never another project's, and rereads a
    project file that changed. The project is the one pmcp serves -- --project when
    given, otherwise the project root found from the working directory (the root
    .mcp.json is loaded from) -- so pmcp --project B started inside project A spawns,
    gates and checks B's servers with B's credentials. Every project input follows that
    same project: its .mcp.json, its .pmcp/manifest.yaml overlay (found at the
    project root, no longer by walking up from the working directory; a directory with an
    overlay is a project root) and its .mcp-gateway-policy.yaml (no longer read from the
    working directory), so a server's endpoint and its credential always come from one
    project. pmcp init without --project writes the served project's .mcp.json.
    Started from a subdirectory of a
    project, pmcp reads the root's .env.pmcp, the one pmcp secrets set --scope project
    writes, where 2.7.3 loaded the working directory's (a tenant
    lookup checks the tenant store before the project store), where in 2.7.3 a checkout's
    .env won over the user store and pmcp secrets check preferred a project
    .env.pmcp (a per-project override of a user-store key, such as a tenant id, does not
    apply), and a variable your shell exports as empty stays
    unavailable rather than being filled from a project file. A ~/.env (or one in an
    ancestor of your home directory) still loads as before. A server pmcp spawns
    keeps every variable you exported: a project .env.pmcp that lists a name, such as
    NO_PROXY or SSL_CERT_FILE, no longer removes your value of it from the child's
    environment, as 2.7.3 did. Only the names in your user store and the credentials pmcp
    itself put into its environment (auth_connect, a ~/.env) are withheld from other
    servers. A tenant or project store located without --project is the served
    project's. pmcp secrets sync --from-scope project --to-scope user copies credentials only -- a credential-shaped
    name or one a manifest server declares (POSTGRES_URL) -- and skips every other
    name (UV_INDEX_URL, DATABASE_URL, anything named PMCP_*, proxies, code-loading and
    package-manager names) and any value containing ${ or spanning lines, with pmcp: Not copying <NAME> from .env.pmcp: …, where 2.7.3 copied every key into the store
    pmcp loads into its environment at each start; gateway.auth_connect refuses the
    same names and a credential containing ${. pmcp secrets check answers what the
    runtime would: an exported variable counts as available, an exported empty one as
    missing, where it used to look at the stores alone.
    Security
  • HOME must be a plain absolute path. pmcp uses the files under your home directory
    -- user config and policy, guidance, the user manifest overlay, ~/.config/pmcp/pmcp.env,
    the trust and package-approval stores, the registry caches, the lock -- only while
    HOME (USERPROFILE on Windows) is absolute, has no . or .. component and
    resolves, and no checkout lies above the home directory or holds a link on the way to
    it. Otherwise pmcp prints pmcp: Ignoring the operator's files under the home directory: … once, creates nothing there, and refuses every trust and
    package-approval decision; the gateway does not start with its default lock under
    ~/.pmcp (it exits with status 1), and started with --lock-dir outside any checkout
    it runs without those files. A dotfiles repository at the home directory, your
    own links (/home -> /var/home) and a filesystem or drive root holding a marker (a
    container's /package.json) are fine. 2.7.3 used whatever HOME named. Security
  • Discovered servers are default-deny. gateway.register_discovered_server resolves
    and pins the package (and refuses one it cannot pin, or an env_vars name that is not
    credential-shaped); provision, connect_server and restart_server refuse it until
    pmcp trust approve-package <name>@<version> or a packages.allowlist match; and
    gateway.update_server refuses every discovered server. Security
  • New packages: policy section. A packages.denylist entry also refuses a
    manifest server whose npm package it names, and, while any denylist is in force, a
    manifest entry whose npx packages pmcp cannot determine. A version-bearing glob such
    as pkg@1.2.3 makes the policy file invalid. Added, Security
  • Feedback submission is off by default. gateway.submit_feedback posts only after
    pmcp guidance --feedback-submission on and with a shell-exported
    PMCP_FEEDBACK_TOKEN; confirm_submission=true alone now returns a preview.
    GITHUB_TOKEN, GH_TOKEN and the gh issue create fallback are gone, a
    PMCP_FEEDBACK_REPO from a checkout's .env is refused, the default repository is
    Consiliency/pmcp, a preview reports repository_visibility: "unknown", and the
    output gains submission_outcome. Added, Removed, Security, Fixed
  • Auth URLs must be canonical. A JWKS URL, protected-resource metadata URL or
    URL-mode elicitation URL whose host is not a canonical DNS name, dotted-quad IPv4 or
    bracketed IPv6 address is refused, as is one containing a control character or a
    backslash, or an IPv6 host that is not public itself or embeds a non-public IPv4
    address. In HTTP resource-server mode pmcp refuses to start on an unusable JWKS URL or
    required scope, and create_http_app refuses a bad metadata URL instead of dropping
    the route. Fixed
  • Auth responses changed. Some 401 descriptions are reworded (Empty token., The token's algorithm is not supported.), as is the shared-secret startup error. An
    unknown kid refetches the JWKS at most once per 10 s, and any JWKS failure, including
    a key set with no usable keys, is a 503 instead of a 500. The metadata route's
    resource is --oauth-audience, or the metadata URL's origin plus /mcp, and no
    longer the request Host. A forged token pairing an algorithm with a key of
    another type, and a non-ASCII Authorization header, get a 401 instead of a
    500. Security, Fixed
  • The tools/call gate enforces the schemas pmcp advertises. Constraints the
    argument models always had are now rejected at the gate as an isError
    Input validation error: … result instead of an {"error": true} payload. Lax
    coercion (1 for a boolean, "5" for an integer) on invoke.task is refused, and
    an explicit null for an optional argument is now accepted. Policy is judged before
    the schema, and gate rejections are recorded as audit.rejection events, which carry
    no values taken from the call's arguments: only the failing path and the JSON
    Schema keyword, never the value or any digest of the arguments. An
    audit.invocation record for a call refused by policy or made to an unregistered
    name is denied with every argument-derived field (run_correlation_id and the
    rest) null, and every other record reads only the top-level arguments the tool's
    schema declares, so a correlation id passed to a tool that does not declare it is
    no longer recorded. Changed
  • Task numbers are bounded. invoke.task.ttl must be an integer from 1 to
    9,007,199,254,740 (seconds), and invoke.task.poll_interval a finite number above 0
    and at most 9,007,199,254,740. NaN and
    ±Infinity are refused for every numeric argument, and a request carrying a value
    that is not strict JSON fails with outbound frame is not strict JSON. A downstream
    task field pmcp cannot use is reported as null and named in unusable_fields.
    Finished tasks past the 100-record cap are evicted in the order pmcp recorded
    them, not by the downstream's timestamps. Changed
  • Task ttl and poll_interval are seconds in pmcp and milliseconds on the wire.
    pmcp now converts both ways, as MCP 2025-11-25 requires. If you sent milliseconds
    to work around the old pass-through, your values are now 1000× too long. A tenant
    server built to the old seconds contract now receives milliseconds and must return
    ttl/pollInterval/poll_interval in milliseconds. A task's ttl is now a
    fractional number of seconds. Changed
  • Redaction removes more. sanitize_auth_diagnostic, PolicyManager.redact_secrets
    and process_output now also replace vendor token shapes, JWTs, PEM private keys,
    high-entropy runs, URL userinfo and secret query values with [REDACTED]. Existing
    markers are unchanged. Security
  • Manifest version pins. A manifest entry's version:, or an overlay's
    server_version:, now pins an npx server to that exact version; an invalid pin is
    ignored with a warning. gateway.update_server does not move a pinned server, and
    pmcp update prints [PINNED] for it. Added
  • An overlay entry pmcp cannot use is skipped. A manifest overlay entry with a
    field some part of pmcp would fail on (keywords: [1], an args list holding a
    number, a command or transport that is not a string, a cli_alternatives
    entry with an empty check_command) is skipped
    when the overlay is read, with a WARNING naming the field but not its value. 2.7.3
    loaded it, and then gateway.catalog_search failed for every query, or startup and
    gateway.refresh failed for every server. A CLI an overlay adds or replaces now
    ranks after every server and after pmcp's own CLIs. Fixed
  • Downstream servers see more from pmcp. A server→client request now gets an
    answer (ping gets an empty result, anything else -32601), cancellation is sent as
    notifications/cancelled, and a malformed frame is dropped instead of ending the
    connection. Fixed
  • Logs. Every install spawn, package-runner start and update probe logs a
    secret-safe command line at WARNING. A manifest's warnings are logged once each time
    its inputs change, not on every load. Changed
  • Dependency floors. pyjwt[crypto]>=2.15.0 (was >=2.10.0),
    aiohttp>=3.14.2 (was >=3.9.0), python-dotenv>=1.2.2 (was >=1.0.0) and,
    in the http extra, starlette>=1.3.1 (was >=0.27.0); the dev extra needs
    pytest>=9.0.3 (was >=7.0) and adds pytest-timeout>=2.3. Security
  • Agent-facing hints. The try/catch code hint is now try, and the Playwright
    screenshot pattern and example name browser_take_screenshot with filename.
    Changed
  • A symlinked .mcp.json is no longer edited. pmcp config set-startup-policy
    and the gateway.set_startup_policy tool refuse to rewrite a .mcp.json that is a
    symlink -- for the user, project and custom sources alike, and in a dry-run preview
    too -- reporting symlinked_config/invalid_source. 2.7.3 accepted the edit and
    replaced your symlink with a regular file. Edit the link's target directly. Changed
  • NaN from HTTP/SSE servers. Downstream replies over HTTP and SSE are read as
    sent: a NaN inside a tool result now reaches the caller as a NaN token rather
    than null (as stdio servers already did; tracked as
    Consiliency/pmcp#335), and a
    nextCursor of NaN leaves the previous listing in place. Changed
  • Error text names the real failure. Status, doctor, health output and
    connect/disconnect errors from a remote transport now show the individual
    exceptions inside an exception group instead of unhandled errors in a TaskGroup. One line still prints the old string: the batch-connect
    Failed to connect to <server>: …, also shown as pmcp's "cannot reach PMCP
    gateway" error; the WARNING lines before it name the cause. Match on the
    underlying error instead. A hung gateway.update_server probe on Python 3.10
    reports Update probe timed out after 60 seconds. instead of an empty
    Failed to run update probe: . Fixed
  • A cancelled teardown kills stdio servers at once. When the caller of a
    disconnect, restart, refresh or shutdown is cancelled (or shutdown's 10 s budget
    runs out), pmcp now SIGKILLs the server's whole process group instead of waiting
    out its SIGTERM grace, so a server that needs a graceful flush on exit can lose
    it in that case. An uncancelled teardown still sends SIGTERM first. Fixed
  • Stop every running gateway before upgrading. 3.0 never removes the singleton lock
    file ~/.pmcp/gateway.lock (a lock dies with its gateway), and refuses a lock path that
    is a link or not a plain file, and an explicit --lock-dir that is itself a link (a symlinked ~/.pmcp is judged like ~/.config/pmcp: followed unless it leads into the project pmcp serves or was started in). 2.7.3 removes the file when it shuts down, so while an
    older gateway still runs, its shutdown can delete a 3.0 gateway's live lock and a third
    start can then run beside it. Until every running pmcp is upgraded the one-gateway
    guarantee does not hold; stop them all first, and do the same before rolling back.
  • Known issues in 3.0.0. pmcp refresh writes its cache to .pmcp by default,
    but the gateway reads .mcp-gateway; until that is fixed, run
    pmcp refresh --cache-dir .mcp-gateway
    (Consiliency/pmcp#352).
    Tenant-aware header resolution for remote servers is not wired yet
    (Consiliency/pmcp#353).

Added

  • Version pinning, piece 1: pins only. A manifest entry's version:, or an
    overlay's server_version: {<server>: <version>}, holds an npx-launched server at one
    exact version. pmcp writes the pin into the server's args and every platform's
    install argv, keeping the package name, all or nothing. A pin is ignored with a
    warning, and the entry is left unchanged, when it is not one exact version, the package
    slot is not a plain registry spec, the launcher is not a bare npx, the server is
    remote (url), or the entry's own env could redirect npm. Build metadata (+…) is not
    an exact version. Refusal warnings never include the pin value or an argv, and
    name a server only if pmcp ships it. gateway.update_server does not move a pinned
    server, and pmcp update reports it as [PINNED]. A refused pin's warning states
    its consequence by the loader's merge order, still with no pin values: a refused
    server_version says any earlier pin stands unless a servers: entry for that server
    in the same or a later source replaced it, or a later source set another pin; a refused
    version: says its whole entry replaced any earlier pin, so the server is unpinned
    unless that source's server_version or a later source pins it. pmcp update prints
    [FAILED] … Could not determine a registry package for a pinned server, and moves
    nothing, when the entry sets an npm setting, when the gateway's own environment sets
    any npm_config_* variable or NODE_OPTIONS, or when the directory the gateway runs
    in, or any directory above it, contains a package.json or a node_modules
    directory; the pin is still in every argv, and the README lists the three cases
    (Consiliency/pmcp#322). See
    Consiliency/pmcp#294; the plan merged
    in Consiliency/pmcp#317.
  • pmcp guidance --feedback-submission on|off, and the
    enable_feedback_submission key it writes.
    This is the only setting that lets
    PMCP post a feedback issue to GitHub on your behalf, and it is off by default.
    The verb mirrors pmcp guidance --telemetry on|off: it writes
    guidance.enable_feedback_submission into ~/.claude/gateway-guidance.yaml —
    user-scoped and outside any checkout, so a repository cannot ship its own consent —
    and pmcp guidance now prints Feedback Submission: ✓/✗ beside the telemetry
    line. off writes an explicit false rather than deleting the key, so the file
    records the decision rather than the absence of one, and a newly generated default
    config states the key rather than leaving an operator to know the model's default.
    confirm_submission=true no longer causes a post on its own. That argument is
    the user's consent to the exact payload; the flag is the operator's authority
    to send it, and an agent cannot set the flag. With the flag off — which is every
    gateway that has not run the verb — gateway.submit_feedback returns the built
    payload and a browser URL and pmcp sends nothing, which is what it already did for
    an unconfirmed call. Telemetry still outranks it: enable_telemetry: false
    refuses before the submission flag is consulted, and says so rather than telling
    you to turn on a switch telemetry would override. See
    Consiliency/pmcp#230.
  • gateway.submit_feedback output gains submission_outcome. Optional, absent
    (null) on every preview and on every refusal made before a request — so no
    existing field changes and no existing reading of submitted breaks. When pmcp did
    attempt a post it is one of created, refused, not_dispatched or
    dispatched_unconfirmed. The last is the one submitted: false cannot express on
    its own: the request left this machine and no response came back, so the issue
    may exist
    . On that outcome issue_url is a GitHub search URL for the title
    rather than a pre-filled compose URL, and the message says the issue may already
    be filed — handing back a compose URL after a possible success is how the same
    issue gets filed twice. refused is a real negative: GitHub itself answered, so
    the compose URL is safe. See
    Consiliency/pmcp#230.
  • pmcp trust approve-package|list-packages|revoke-package, and a packages:
    section in the gateway policy.
    These are the two ways an operator opts a
    discovered package in (see Security below). pmcp trust approve-package <name>@<version> records an approval for that one package at that one exact
    version in ~/.config/pmcp/package_approvals.json, beside the trust store and
    under the same rules: it must live outside the checkout, it is written mode
    0o600, and any read failure refuses rather than grants. The version must be
    one exact SemVer version, the one a refusal message prints; a range or a
    dist-tag such as latest is refused, because it names whatever is published
    next. Approving is offline and consults no registry. pmcp trust list-packages prints every record (decision, time, registry:name@version,
    integrity), and pmcp trust revoke-package <name> drops every version's
    approval while pmcp trust revoke-package <name>@<version> drops just that
    one; it exits non-zero if there was nothing to drop. A version bump is a new
    package
    and needs its own approval.
    The policy's new packages: section takes allowlist and denylist globs,
    and they match the package name only, never name@version, so a package
    author cannot satisfy *-mcp by publishing version 1.0.0-mcp. A
    version-bearing entry such as evil-pkg@1.2.3 would therefore match nothing,
    so instead of being accepted it makes the policy schema-invalid when it
    loads, with the same consequence as any other invalid policy file. A
    packages.allowlist match lets a discovered package provision without a
    recorded approval. A packages.denylist match refuses it even when an
    approval is recorded, and also refuses a manifest-backed server whose npm
    package it names. A project .mcp-gateway-policy.yaml can add a denial
    but its allowlist never grants on its own. See
    Consiliency/pmcp#230.
  • pmcp trust approve|list|revoke, and a user-scoped trust store at
    ~/.config/pmcp/trust.json.
    The store records one decision per absolute
    path — the file's SHA-256, the scope, the decision and when it was taken —
    and answers one question: are these bytes approved to be applied as this
    path
    ? is_approved(path, content) hashes the content the caller is about to
    use and never reads the path, so a file swapped between the check and the use
    is not covered by the earlier approval; editing an approved file by one byte
    makes it unapproved with no further action. approve records the file's
    current bytes and replaces any earlier decision for that path (there is no
    history); list prints decision, digest, timestamp and path; revoke drops
    the record, returning the path to absent, and exits non-zero if there was
    nothing to drop.
    The store is what the project-consent gate reads (see Security): an
    unapproved project .pmcp/manifest.yaml, .mcp.json or
    .mcp-gateway-policy.yaml is ignored until pmcp trust approve <path>
    records its current bytes. Package approvals live in a sibling file under the
    same rules.
    Two properties are worth knowing before you rely on it. The store must live
    outside the checkout it judges — a store path that resolves inside the
    current repository (or, under pmcp --project, the served project),
    directly or through a symlink, is refused rather than
    read, and pmcp trust approve also refuses a store inside the checkout that
    contains the file being approved, so it never reports an approval that
    pmcp --project would then refuse
    (Consiliency/pmcp#252),
    because a repository that ships its own approval record must not be
    believed. And every read failure is a refusal: a missing, unreadable or
    corrupt store makes is_approved answer False rather than raise, so a
    broken file can never be mistaken for permission. The operator-facing verbs
    do the opposite and fail loudly, because a silent failure there would hide
    the broken store. The store file is created mode 0o600.
    Also added: resolve_package_identity(), which resolves an npm spec to
    (registry, name, resolved_version, integrity) from the registry's metadata
    document alone — no install, no npx, no subprocess — and returns nothing at
    all for a range, an unknown dist-tag or any spec it cannot pin to one
    concrete published version. gateway.register_discovered_server uses it to
    resolve and pin a discovered package before storing it (see Security). See
    Consiliency/pmcp#230.

Removed

  • The gh issue create fallback in gateway.submit_feedback is gone. When the
    gateway had no API token it used to shell out to gh issue create --repo … if the
    gh CLI was on PATH. If you relied on that, submission through pmcp now stops
    working for you
    and the call returns a preview with a browser URL instead: it
    names PMCP_FEEDBACK_TOKEN and the pmcp guidance --feedback-submission on verb,
    and the URL still files the issue by hand. The path was removed rather than gated
    because gh authenticates from whatever the inherited environment and gh's own
    stored credentials provide, so pmcp could not say — and could not tell you —
    which identity posted. The handler's last asyncio.create_subprocess_exec and the
    file's last shutil use went with it: this path now spawns nothing. See
    Consiliency/pmcp#230.
  • GITHUB_TOKEN and GH_TOKEN are no longer read anywhere on the feedback
    path.
    Previously an ambient GITHUB_TOKEN was accepted as the posting
    credential, so an agent that asked to submit could publish under an operator's
    personal GitHub identity — one that generally carries far more authority than
    filing an issue. Only PMCP_FEEDBACK_TOKEN is honoured now, and an operator
    who was posting on an ambient token must export that variable instead (a
    fine-grained token with issue-write on one repository is enough). With no such
    token the call previews and names the variable; it does not fall back to anything.
    GITHUB_TOKEN still appears in gateway.register_discovered_server's schema
    example for a downstream server's own credentials, which is unrelated and
    unchanged. See Consiliency/pmcp#230.

Security

  • No reader follows a repository-controlled credential store out of the project.
    #366 confined the startup load and every command that rewrites a
    project .env.pmcp, but other readers still read through a link to a file outside
    the project. The worst was remote-header auth: a ${VAR} in a remote server's
    headers was filled from the project store, so a cloned repository that shipped
    .env.pmcp as a link to another file you own, plus a remote server pointing at a
    host it chose, could put that file's values in an Authorization header to that
    host — through the gateway's config load, a remote connection, pmcp status and
    pmcp doctor. The same was true of a tenant store
    .pmcp/tenants/<id>/pmcp.env, of the gateway's credential-availability check (which
    loaded the linked file — and the checkout's .env — into the gateway's own
    environment), of the feedback gate's planted-key check, of the spawn-time
    credential checks and env stripping, and of pmcp secrets check. Every one of them
    now reads through a single store reader that takes the store's scope: the user
    store ~/.config/pmcp/pmcp.env still follows its link (a dotfiles repository), and
    every repository-controlled file — the project .env.pmcp, a tenant pmcp.env, and
    the .env and .env.pmcp at the served project root, and the .env that
    startup discovers by walking up from pmcp's installed files (which, for pmcp
    installed in a checkout's .venv by uv run or pip install -e, is the
    checkout's own .env) — is read only through the confined walk, which refuses a
    symlink of any kind (#366). The discovery itself is kept: for a uv tool or pip --user
    install the walk reaches ~/.env, in the home directory, which is the operator's
    own and loads as before; a .env the walk finds anywhere other than the home
    directory or an ancestor of it is a repository's, whatever marker files the checkout
    has. A symlinked store, or a file that is not regular, is read as empty with one
    value-free line on stderr (pmcp: refusing to read .env.pmcp: it is a symlink, or
    refusing to load for the startup .env and the credential check), once per store, reason and file identity in each configuration
    load, so a store that changes, or is still refused at the next load, is reported
    again; the feedback gate refuses to submit (gate_error) instead. A tenant id made
    only of dots is refused: .. and . named .pmcp/pmcp.env and
    .pmcp/tenants/pmcp.env, which are no tenant's store.

    Reading confined was not enough on its own. A regular project file could still
    steer what pmcp did next by putting variables into its environment: with HOME
    unset, a .env.pmcp that set HOME moved the user store onto a link the checkout
    shipped; Http_Proxy (httpx takes any case) sent every outbound request, the
    Authorization header included, through a proxy the repository named;
    SSLKEYLOGFILE wrote TLS session keys to a file it chose; and LD_PRELOAD,
    NODE_OPTIONS, UV_INDEX_URL or PYTHONPATH reached the children pmcp starts
    without a filter — systemctl for pmcp doctor and pmcp status, the service
    restart, pmcp upgrade, the CLI probes and the npm resolver — and systemctl
    loaded the repository's library. A list of such variables can never be complete,
    so there is none: a repository-controlled file never populates pmcp's environment.
    Its values go to a separate credential map that only explicit credential lookups
    consult (env_store.credential_value), and a name pmcp itself reads from its
    environment, in any case, is never answered from it. The user store's path is fixed
    before anything else loads. Every child pmcp starts takes its environment from one
    builder (env_store.child_process_env, which the server-spawn filter builds on).

    Absence is now only "no such file". A check that a store or its root exists used to
    answer "no" when a directory above it could not be searched (EACCES), so a store
    pmcp could not read counted as no store, and the feedback gate's strict check
    allowed a submission; that lookup now raises, and the gate refuses (gate_error).
    Readers that only look (startup, header lookup, the credential check, env
    stripping, pmcp secrets check) warn once and read such a store, the user store
    included, as empty.

    Tests walk the source tree's syntax and fail if any function reads or loads a store
    any other way, if a credential lookup reads os.environ by name instead of
    credential_value, if any process is started without an env= from the builder,
    if atomic_write or env_store asks a yes/no existence check, or if src/pmcp
    reads an environment variable that is not classified as path-and-trust,
    provenance-gated or operational, or if anything but credential_value reads the
    credential map or a store's values. That classification inventory sees only pmcp's
    own reads, not what httpx, ssl or a child process read; with repository files kept
    out of the environment, that limit no longer matters for them.

    The values a repository file supplies are expanded only within that file, never from
    your environment or user store, so LEAK=${PMCP_AUTH_TOKEN} cannot copy a secret into
    a name a repository-configured header then sends. Every lookup -- the startup map,
    the project and tenant stores behind remote-header resolution, pmcp secrets check --
    goes through the one gate, so a repository's PMCP_AUTH_TOKEN, HTTP_PROXY or
    SSLKEYLOGFILE never fills a header either, and precedence is by presence: a
    variable your shell exports, even as empty, is never filled from a project file. See
    #367.

  • Bumped multidict 6.7.0 → 6.9.1 to clear advisory GHSA-54p9-h82j-f925.
    multidict is transitive (aiohttp → multidict, and aiohttp → yarl →
    multidict), so this is lockfile-only, like the anyio bump: the repo floors
    direct dependencies in pyproject.toml and locks transitive ones. The D-01
    pip-audit --strict gate is green again.

  • Unknown-kid tokens can no longer drive an outbound JWKS fetch per request.
    A token whose kid is not in the cached key set forced a JWKS refresh every
    time, before rate limiting runs, so an unauthenticated caller sending random
    kids could make the gateway fetch at will. Forced refreshes are now limited
    to one per 10 s window, checked under the refresh lock so a concurrent burst
    shares one fetch; within the window an unknown kid is checked against the
    cached keys and gets a 401. The first unknown kid in each window still
    refreshes, so a rotated key is picked up within about one window (review
    finding S-07). See Consiliency/pmcp#231.

  • A slow or failing JWKS endpoint no longer stalls refresh for every waiting
    request.
    The fetch had no timeout (aiohttp's default is 300 s) and ran under
    the refresh lock. It now has a 5 s total timeout, and a failed refresh opens a
    short (5 s) backoff that the requests queued behind it share, so the last of N
    concurrent requests waits about one timeout rather than N of them and gets a
    503. Keys are never served past their TTL. A refresh rejected by the backoff
    does not use up the unknown-kid window (review finding S-08). See
    Consiliency/pmcp#231.

  • The protected-resource metadata no longer echoes the request Host.
    /.well-known/oauth-protected-resource built its resource from the request,
    so a forged Host was advertised back as the resource, even with the Host
    allowlist configured (that route never checked it). resource is now the
    operator-configured resource_server_audience (--oauth-audience), or, when
    that is unset, the origin of the configured protected-resource metadata URL
    plus /mcp (scheme-default port dropped; any path prefix in the metadata URL
    is not carried over, because the app serves MCP at /mcp) (review finding
    S-10). See Consiliency/pmcp#231.

  • A forged token can no longer turn a resource-server request into a 500.
    The token's header chooses the algorithm and its kid chooses the key, so an
    unauthenticated caller could pair an allowed algorithm with a key of another
    type (for example ES256 against a published RSA key). pyjwt then raised
    TypeError, ValueError or InvalidKeyError while preparing the key, and
    nothing caught it. Those now give the usual 401 invalid_token. Token
    errors whose pyjwt text can quote the token back (such as an unknown crit
    extension) now get a fixed description. See
    Consiliency/pmcp#231.

  • pyjwt raised to 2.15. The dependency floor is now pyjwt[crypto]>=2.15.0
    (2.7.3 declared >=2.10.0; the D-01 entry below first raised it to >=2.13.0) and the lock resolves 2.15.1, picking up the fixes for the
    12 advisories pip-audit reports against 2.13.0 (GHSA-w6j9-cwv2-h6wq,
    GHSA-2gx3-rcp4-g85q, GHSA-w2cx-738m-mc7w, GHSA-hxm8-2xgr-2p9m,
    GHSA-9v7f-9g4p-ffgj, GHSA-ffc3-869f-jxw9, GHSA-r6x4-923q-g947,
    GHSA-p4g4-x82p-q773, GHSA-8wjv-2p76-3863, GHSA-9j54-fg26-wv3r,
    GHSA-jwrc-g2q2-pq5p, GHSA-42vr-xj54-vc7v). The floor matters because a
    pip install pmcp resolves without the lock.

  • Additive shape-based secret redaction on top of the existing redactor.
    The existing redactor runs unchanged; new rules then run over its output
    and can only replace more of it with [REDACTED], so every piece it
    removes stays removed. They add vendor token shapes (AWS, Slack, Google,
    sk-/ghp_/glpat--style prefixes), JWTs, PEM private-key blocks,
    high-entropy runs, values under a wider set of secret keys and in keyword
    lists, URL userinfo, fragments and secret-keyed query values (judged after
    percent-decoding), and the text glued to the end of an existing marker
    (up to a delimiter; see the residuals). The
    existing markers are kept exactly as written, a JSON document stays
    valid (only string values are touched), and the added work is linear in
    the input. The four entry points are sanitize_auth_diagnostic,
    PolicyManager.redact_secrets, and process_output on a string and on a
    structured result. A new slow pytest marker holds the large
    differentials and regex sweeps (pytest -m 'slow and not live'). The
    residual classes are listed in the plan,
    .consiliency/plans/detailed-234-redactor-shape-based-20260923-0921.md. See
    Consiliency/pmcp#234.

  • Bumped httpx2 and httpcore2 2.9.1 → 2.12.0 to clear PYSEC-2026-3844
    (httpcore2) and PYSEC-2026-3845 to PYSEC-2026-3849 (httpx2). Lockfile-only; the
    declared httpx2>=2.5.0,<3.0.0 range already admitted the fix. The lock gains
    httpx2-jsfetch, a transitive of httpx2 2.12.0 that installs only on
    sys_platform == 'emscripten'.

  • Bumped anyio 4.12.0 → 4.14.2 to clear advisories GHSA-82r6-8w77-94w6 and GHSA-5p39-cfhj-2xmp. Lockfile-only; the D-01 pip-audit --strict gate is green again.

  • SECURITY.md now states the v13 trust boundary, and every claim in it is
    bound to a test that proves it.
    A new The v13 trust boundary section
    describes the implemented model as 27 guarantees and 11 labelled limitations,
    each carrying a machine-checkable citation to the test(s) that prove it, inside
    a delimited claim region and ledger. scripts/check_security_claims.py (run by
    the normal CI suite) fails the build if a sentence in that region makes a claim
    no ledger row cites, if a cited test does not exist or would not be discovered
    by pytest tests/, or if any of the v13 evidence files ships uncited — so the
    document cannot drift from the code without turning CI red. The
    vulnerability-report address in that file now points at this project's own
    GitHub security advisories (Consiliency/pmcp) rather than the old
    repository's. See Consiliency/pmcp#230.

  • An install spawn now strips PMCP-managed credentials using the project root
    the gateway was given, not the directory it happens to be running in.
    When a
    gateway started with pmcp --project X ran from a different working
    directory, the install child's environment was sanitized by walking up from the
    working directory to find the project credential store, while the credential
    was written from the gateway's own root X — so a project-scoped credential in
    X/.env.pmcp was not stripped and the install child (npx -y …) inherited
    another server's secret. The strip now resolves the store from the gateway's
    project root, matching where the credential was written; a gateway with no
    --project, or run from its project root, is unchanged. See
    Consiliency/pmcp#230.

  • A checkout can no longer redirect the gateway's manifest, config or policy by
    planting PMCP_MANIFEST_PATH, PMCP_CONFIG or PMCP_POLICY in a .env
    file.
    These three variables choose a manifest overlay, an explicit config, or
    an explicit policy, and were honoured unconditionally on the assumption that a
    set variable meant the operator had exported it. But pmcp loads .env and
    .env.pmcp on the operator's behalf before argument parsing, so a repository
    could set any of the three through its own checked-in dotenv file and choose the
    gateway's manifest, config or policy with no consent gate — the S-03 and S-11
    boundaries through a back door. Each variable is now honoured only when
    provenance shows the operator exported it into their own shell; a value that
    reached the process through any file pmcp loads on the operator's behalf (.env,
    the project .env.pmcp, or ~/.config/pmcp/pmcp.env) is ignored exactly as if
    unset, at both
    the startup and runtime doors, and the skip is logged operator-safe naming the
    variable and path. A value the operator genuinely exported still applies. See
    Consiliency/pmcp#230, Consiliency/pmcp#250.

  • The trust store's checkout-residency guard now keys on the project the
    gateway serves, not only the directory it was launched from.
    A trust store
    that resolves inside a checkout is refused, because a repository must not ship
    its own approval record — but the guard discovered the checkout by walking up
    from Path.cwd(), so pmcp --project <checkout> launched from any other
    directory did not refuse a store planted inside that served checkout, and it
    would load that checkout's self-approved .mcp.json. The guard now judges
    residency against the served project root and the launch checkout (adding
    the served root, never replacing the cwd walk, so a store resident in a second
    checkout the operator launches from stays refused too). pmcp status --project
    gets the same binding; the pmcp trust verbs and a bare pmcp (the gateway) keep
    the cwd-derived guard, and pmcp trust approve additionally refuses a store
    inside the approved file's checkout (see the trust-store entry under Added). See
    Consiliency/pmcp#230, Consiliency/pmcp#251.

  • gateway.submit_feedback will not post under a credential or to a destination
    that PMCP itself introduced.
    Every submission is now decided by one
    fail-closed gate (src/pmcp/feedback_egress.py) before any network call, and the
    gate refuses PMCP_FEEDBACK_TOKEN unless it can establish that you put it in
    the environment. A token PMCP wrote itself is refused: gateway.auth_connect
    stores credentials and exports them into the gateway's own environment, and
    PMCP_FEEDBACK_TOKEN is credential-shaped, so an agent could otherwise have
    stored one through that tool and had pmcp post with it. So is a token a .env in
    the current checkout supplied, and so is one PMCP loaded at startup from its own
    credential stores — recorded as PMCP-introduced when the load happens, because
    the store entry can disappear afterwards (another process rewriting the store,
    or the operator deleting it) while the variable it planted stays in the
    environment. Export the value in the shell that starts pmcp; the refusal
    names both store paths (~/.config/pmcp/pmcp.env and the project .env.pmcp) and
    says so. The check is deliberately over-strict in one direction: if you export the
    variable and the same key sits in a PMCP store, the two cannot be told apart
    afterwards and the call is refused, with a remedy naming both fixes.
    The destination is decided the same way and decided first, so no unvalidated
    repository reaches any output — a refusal included, and the browser URL the agent
    is told to open included. PMCP_FEEDBACK_REPO still overrides the default, but an
    override that a checkout's .env introduced is refused, as is one that is not
    owner/repo shaped, and the refusal renders the offending value inert rather than
    echoing it. A refusal that cannot establish authority or destination returns no
    built issue payload and no URL at all; only the caller's own title and
    description are echoed back. Any error while consulting the provenance records
    denies rather than raising, so a failed lookup can never read as permission. See
    Consiliency/pmcp#230.

  • A discovered server no longer runs an agent-chosen package without an
    operator's approval, and its registration is pinned to one resolved version.

    Before this release, gateway.register_discovered_server stored whatever
    package the agent named, unpinned, and policy checked only the server
    name
    . Allowlisting internal-approved-tool therefore let an agent register
    that name with package="totally-arbitrary-evil-package" and have
    gateway.provision run npx -y totally-arbitrary-evil-package (S-01).
    Registration now looks the package up in the npm registry first, in a worker
    thread under a 20-second bound, and writes the resolved name@version into
    both the install command and the server's args, so every later start
    runs the version that was resolved rather than re-resolving latest. A
    package that does not resolve to exactly one valid version, or whose lookup
    times out, is refused at registration and nothing is stored.
    Discovered servers are now default-deny. gateway.provision,
    gateway.connect_server and gateway.restart_server refuse a discovered
    server until its exact name@version is approved with
    pmcp trust approve-package <name>@<version> or matched by a
    packages.allowlist entry in the operator's policy. A packages.denylist
    match beats both. The refusal names the package and prints the runnable
    approval command, and the registration response warns up front when
    provisioning will be refused. provision checks this before it asks for
    the server's credential, so an agent is never prompted for a secret for a
    package that will be refused. The lifecycle tools are gated as well as
    provision because npx -y fetches and runs the package when it spawns, so
    without the gate register followed by connect_server would run the package
    without provision ever being called. gateway.disconnect_server is not
    gated, so an already running server can still be stopped.
    gateway.update_server now refuses every discovered server, approved or
    not: an approval covers one exact version, and an update would fetch another.
    To move a discovered server to a newer version, call
    gateway.register_discovered_server again with the same name and package
    (which resolves and pins the current version), approve that version, then
    connect it.
    "Exactly one valid version" follows npm's own classification of package
    specs: a version ending in .tgz, .tar or .tar.gz (any case) is refused,
    matching npm-package-arg's isFileType rule, and so is a version whose major,
    minor or patch exceeds 2^53 − 1 (JavaScript's Number.MAX_SAFE_INTEGER),
    which npm reads as a dist-tag. pmcp uses npm 10's pattern, a superset of
    npm 11's, since it runs whichever npx is on PATH; registration, the
    provision gate, package approvals and the CLI all share this check.
    A refusal reports auth_state="policy_denied" only when a
    packages.denylist entry matched. Every other refusal from this gate (not
    approved, unresolvable identity, or argv not pinned to the approved version)
    reports auth_state="unknown", because no policy denied it.
    Manifest-backed and .mcp.json servers need no approval, and provision,
    connect, restart and update exactly as before, with one exception: a
    packages.denylist entry now refuses a manifest-backed server whose npm
    package it names
    (the entry's package field, or a package its npx
    command or install command runs: the package argument, or any package chosen
    with -p, --package or --package=; after such a selector the next
    argument is the command npx runs, not a package). Such a server is refused by
    provision, connect_server, restart_server and update_server, with the
    same policy_denied refusal. While any packages.denylist is in force,
    a manifest entry whose npx packages cannot be determined is refused the same
    way
    : one with an npx option pmcp cannot interpret (such as --registry or
    -c), a selector with no value, or a selected package that is not a plain
    npm spec (such as github:owner/repo or ./dir). The refusal message names
    the argument. Without a denylist those entries behave as before, and no
    shipped manifest entry is affected. If evaluating the package policy raises
    an error, those four tools now refuse a manifest server instead of
    proceeding. .mcp.json servers are not checked against the package lists. There is nothing to
    migrate: discovered registrations are held in memory only and never survived
    a gateway restart, so the first provision of a discovered server after
    upgrading is refused with the command that approves it. Found by the
    2026-09-01 codebase review (S-01); see
    Consiliency/pmcp#230.

  • A discovered server may now declare only credential-shaped environment
    variable names.
    gateway.register_discovered_server refuses, before it
    contacts the registry, any env_vars entry that is not credential-shaped (it
    must end in _TOKEN, _KEY, _SECRET, _SECRETS, _PASSWORD,
    _CREDENTIAL, _CREDENTIALS, _PAT, _DSN or _AUTH), any name that
    influences how code loads (LD_PRELOAD, NODE_OPTIONS, PATH, PYTHON*
    and the rest of the existing list), and any name starting with NPM_CONFIG_,
    NODE_, COREPACK_, YARN_, PNPM_ or BUN_ in any letter case, even when
    it is credential-shaped (NPM_CONFIG__AUTH, NODE_AUTH_TOKEN).
    gateway.auth_connect applies the same rule to a discovered server, both to
    the name it declared and to an explicit env_var override. Before this, an
    agent could register a package the operator had already approved under a
    new server name with env_vars=["npm_config_registry"], store an attacker's
    registry URL through auth_connect, and have the pinned npx -y name@version
    spawn receive it. npx then fetched that version from the attacker's registry:
    the approved name and version, but different bytes. The stored value also
    landed in the gateway's own environment. The old check was a blocklist, and
    a blocklist cannot work here, because the declared name is chosen by the
    agent. Manifest-backed servers keep the previous rule, so a shipped server
    that declares a non-credential name such as POSTGRES_URL works as before. A
    server that needs any other kind of variable should be configured in
    .mcp.json rather than registered. Found by the PKGID phase review; see
    Consiliency/pmcp#230.

  • A repository's own configuration files no longer configure PMCP until you
    approve them, and an approved project policy can only narrow the operator's
    policy.
    The behaviour an existing operator will feel first is the policy one.
    A checked-in .mcp-gateway-policy.yaml used to be discovered ahead of the
    user's own policy and PolicyManager stopped at the first file it found, so a
    project policy replaced ~/.claude/gateway-policy.yaml outright — a
    repository could allow a server the operator had denied (S-11). Both files are
    now read, and every predicate is conjunctive: a server, tool or resource is
    permitted only if the user policy permits it and the project policy does. A
    project allowlist cannot re-admit what the user denied; a project limit can
    lower the operator's effective limit but never raise one, and a limit the
    project does not set leaves the operator's alone; a project redaction list
    extends DEFAULT_REDACTION_PATTERNS rather than replacing them. A project
    policy can still refuse what the user allows — that is the one thing a
    repository is allowed to do to your policy.
    The same gate now stands in front of all three repository-supplied sources:
    .pmcp/manifest.yaml, the project .mcp.json and .mcp-gateway-policy.yaml.
    Until you run pmcp trust approve <absolute path> each one is ignored, and
    the skip is logged once at WARNING naming that exact command. An unapproved
    manifest overlay can neither replace a shipped server's command (S-03) nor add
    a server of its own, and a server it tried to add is not treated as
    manifest-backed downstream, so it cannot collect an exemption meant for servers
    PMCP ships. An unapproved project .mcp.json is not applied by any of its five
    readers — servers, config sources, disableAutoStart, autoStart and
    allowPrivateRegistry — all of which now pass through one choke point rather
    than gating themselves. The project overlay search stops at $HOME, as the
    .mcp.json search already did, so your user-scoped ~/.pmcp/manifest.yaml
    is never mistaken for a project overlay that needs approval.
    User-scoped and explicitly-configured sources are unaffected. ~/.mcp.json,
    ~/.claude/.mcp.json, ~/.claude/gateway-policy.yaml, an explicit --config
    path and an explicit --policy path all apply with no trust record at all,
    and an operator with no project files in their checkout sees byte-identical
    behaviour to before. (The $PMCP_MANIFEST_PATH, $PMCP_CONFIG and
    $PMCP_POLICY environment forms are unconditional only when the operator
    exported them; a value a checkout's .env/.env.pmcp planted is now refused —
    see the trust-boundary entries above.) Approving nothing changes nothing for
    them.
    Approval is over bytes, not paths: editing a file you approved revokes the
    approval by itself, with no pmcp trust revoke to remember. Each source is read
    exactly once and the bytes that were judged are the bytes that get parsed, so a
    file swapped between the check and the use is not covered by the earlier
    approval. Every failure refuses — an unreadable source, an unreadable or corrupt
    trust store, any error out of the store — because a store that cannot be
    consulted has granted nothing. Found by the 2026-09-01 codebase review (S-03,
    S-11); see Consiliency/pmcp#230.

  • The operator's project .env no longer reaches the servers PMCP spawns.
    pmcp's entry point loads a .env (found by python-dotenv's default upward
    search) into its own environment at startup, and _check_api_key_available loads whole env files to answer a
    boolean — while sanitized_subprocess_env builds every downstream server's
    environment from os.environ.copy(). Every key in the operator's .env, not
    just PMCP's own, was therefore inherited by every third-party MCP server the
    gateway launched. Both load sites now record the keys their load_dotenv call
    introduced into the process, and the subprocess sanitizer strips exactly those.
    The mechanism is recorded provenance, never a re-parse: load_dotenv's
    semantics are untouched — interpolation, override=False precedence and the
    credential-availability boolean are all bit-for-bit unchanged — and a variable
    the operator exported into their shell that merely shares a name with a .env
    entry is not in the delta, so it is not stripped (stripping by name would have
    deleted the operator's PATH from every spawned server). A server's own
    declared env_var may still come from a plain .env: own_env is applied
    after the strip, so that key reaches that server and nothing else in the file
    does. Shell-exported secrets are still inherited, deliberately. Found by the
    2026-09-01 codebase review (S-02); see
    Consiliency/pmcp#229.

  • Dependency advisories on the auth path are closed, and CI now fails on new
    ones.
    pip-audit reported nine advisories against the shipped lockfile; the
    one that mattered most was PYSEC-2026-176 in PyJWT 2.10.1, a verifier-side
    algorithm allow-list bypass in jwt.decode()
    — the exact call in
    src/pmcp/auth.py, and the exact control SECURITY.md promises ("signatures are
    only accepted for the operator-configured algorithm allowlist; the token's own
    alg header is never trusted"). Also closed: PYSEC-2026-175 (PyJWKClient
    passing its URI straight to urllib.request.urlopen) and PYSEC-2026-177 (an
    unknown-kid token forcing a JWKS refetch), both adjacent to the auth work in
    Consiliency/pmcp#210 /
    Consiliency/pmcp#211; advisories in
    cryptography (which performs the signature verification) and aiohttp (the
    JWKS fetch client); plus starlette, python-multipart, python-dotenv,
    click and pytest. aiohttp's declared floor is raised to >=3.14.2 and
    PyJWT's to >=2.13.0 (raised again to >=2.15.0 later in this release; see
    the pyjwt entry above), because a floor that admits a vulnerable range is not a
    pin. A new blocking audit CI job runs pip-audit --strict against the
    resolved environment, so the next advisory is a red X rather than something a
    manual review finds months later. Found by the 2026-09-01 codebase review
    (D-01); see Consiliency/pmcp#228.

Fixed

  • The singleton lock file is never removed. Shutdown used to unlink ~/.pmcp/gateway.lock after releasing it, so a second gateway that locked the same file in between lost its lock to a third that created a fresh file: two gateways ran at once. The file now stays; a lock is held only by a running gateway and dies with it, so a leftover file never blocks a start. A gateway that locks a file the path no longer names (unlinked or replaced) retries. pmcp doctor reports whether a gateway holds the lock rather than whether the file exists. See #367.

  • An overlay no longer hides other servers from discovery, and one bad overlay entry
    no longer takes down the others.
    Discovery weighs a keyword by how many servers
    declare it, and it counted over the merged manifest. So an approved project overlay
    (or a user or PMCP_MANIFEST_PATH overlay) whose server shared a keyword pushed
    every server with that keyword below the match threshold. gateway.catalog_search
    then returned no candidate, not even the shipped or user server. Keyword weights now
    come from the shipped manifest alone, so an overlay can add candidates and never
    removes one, within the result limit.

    Separately, a wrongly typed field in one overlay entry made catalog_search fail
    for every query (keywords: null, which now means no keywords; a bad
    cli_alternatives entry) or for every
    query that matched the entry (a non-string transport), or stopped gateway startup
    and gateway.refresh for every server (an int in args). An overlay entry that any part of pmcp would reject is now
    skipped when the overlay is read; an entry nothing would fail on still loads as
    before. The warning names the field, never its value, and shows an overlay entry's
    name only when pmcp ships that name. Loading, discovery, CLI probing and the startup
    and refresh skip lines no longer log an overlay entry's name (unless pmcp ships it)
    or values either, including values a
    .mcp.json entry inherits from an overlay (the self-reference warning now names the
    field, not the command). A blank (null) field now means "not set" and takes its
    default, instead of dropping the entry. One overlay entry also no longer stops other
    .mcp.json servers from loading when one of them inherits its defaults, or makes
    pmcp secrets drop other servers' auth metadata. An overlay that replaces a shipped
    server no longer changes how gateway.request_capability picks a category for
    anything else, and a CLI an overlay adds or replaces is recommended only when no
    server matches and is listed after pmcp's own CLIs in cli_hints. See
    Consiliency/pmcp#342.

  • A cancelled caller keeps its cancellation through every server teardown,
    and the teardown still completes.
    A caller cancelled while a server
    connection is being torn down -- a failed or cancelled handshake,
    disconnect_server, a reconnect's cleanup, disconnect_all, shutdown --
    keeps its cancellation and returns at once; the server's process tree is
    SIGKILLed (through the process group recorded at spawn, so a grandchild
    that outlived its leader dies too) and the client dropped synchronously,
    and a remote transport is abandoned without its graceful close. A stdio
    server is SIGKILLed even when its termination is cancelled, including by
    event-loop shutdown, and shutdown also kills and drops every server when
    its 10 s budget runs out (for example while another operation holds the
    lifecycle lock) or when its disconnect_all is cancelled before it starts.
    A forced disconnect_server cancelled while waiting for a tasks/cancel
    reply, or for the lifecycle lock, finishes its teardown too. A cancelled
    disconnect_all()/refresh() does not respawn the servers it was
    removing. Once shutdown has abandoned every client, nothing connects,
    reconnects or adopts a server process any more (a refresh that was
    mid-flight returns without reconnecting), and a cancelled forced
    disconnect_server also stops a connect of that server sitting in its
    retry backoff. Adopting a provisioned server process now waits for a
    disconnect, disconnect_all or refresh in progress to finish, so it can
    no longer be dropped from the registry with its process still running, or
    respawned by a reconnect a few seconds after disconnect_all returned; a
    provisioning handoff cancelled while it waits kills the process it was
    handing over. Every bounded wait in src/pmcp now uses pmcp.waits.bounded_wait
    instead of asyncio.wait_for, which on Python 3.10/3.11 could drop a
    cancel that landed as the awaited work finished. Where process groups do
    not exist (Windows) the single-process fallback is used, as before. Known
    limits, tracked separately: a cancelled reconnect can still be revived by
    auto-reconnect
    (Consiliency/pmcp#336),
    and a cancel while the spawn itself is completing can leave a grandchild
    (Consiliency/pmcp#344),
    and a connect of a server requested before a cancelled forced disconnect
    can still run afterwards
    (Consiliency/pmcp#359).
    See Consiliency/pmcp#324.

  • An auth URL's host is checked as the HTTP client will read it. A JWKS
    URL, a protected-resource metadata URL or a URL-mode elicitation URL whose
    host was not plain ASCII passed as a "name", but aiohttp (yarl) and
    browsers rewrite such hosts before connecting: https://127.0.0.1/
    (full-width digits), 127。0。0。1 and full-width localhost reached
    127.0.0.1, and 127%2E0%2E0%2E1 decodes to it in a browser. PMCP now
    refuses, rather than rewrites, any host not in canonical form
    (PUBLIC_URL_HOST_NOT_CANONICAL, whose message states the rule): a DNS
    name of dot-separated labels of 1 to 63 ASCII letters, digits and
    hyphens, each starting and ending with a letter or digit, the last
    starting with a letter, so never a number (IDNs in xn-- form); a
    dotted-quad IPv4 address with no leading zeros; or a bracketed IPv6
    address with no zone id. This
    also refuses trailing-dot hosts (localhost., 127.0.0.1.), legacy
    numeric and octal forms that were accepted when public (134744072),
    hosts whose last label starts with a digit (example.123), underscores,
    [v1.fe] and IPv6 zone ids. A control character (U+0000 to U+001F, DEL)
    inside the URL is refused (PUBLIC_URL_CONTROL_CHARACTER) instead of
    being silently deleted (key<TAB>set.json was stored as keyset.json)
    or passed (NUL); leading spaces and C0 controls and trailing tabs, CRs
    and LFs are still dropped. A backslash anywhere in the URL is refused
    (PUBLIC_URL_BACKSLASH): a browser ends the host at it, so
    https://127.0.0.1\@auth.example.com/ is 127.0.0.1 there. An IPv6
    literal that embeds an IPv4 address is classified on that address too,
    now including the IPv4-translated prefix ::ffff:0:0:0/96
    ([::ffff:0:a9fe:a9fe] was accepted), 6to4 and Teredo, and the IPv6
    address itself must be public as well (an ISATAP interface id carrying
    8.8.8.8 under fe80::, fd00:: or ff02:: was accepted); the local-use
    NAT64 prefix 64:ff9b:1::/48 is refused. As before, an address whose
    interface id is ISATAP-shaped and embeds a non-public IPv4 address is
    refused even under a global prefix ([2606:4700::5efe:a00:5]). See
    Consiliency/pmcp#341.

  • The plain-http:// auth URL refusal no longer claims loopback is
    allowed.
    A JWKS URL or metadata URL such as http://127.0.0.1/... was
    refused at startup with "Public auth URL only allows http:// URLs for
    loopback hosts." -- untrue there, since these URLs never accept plain
    http://. The message is now "Plain http:// is not accepted for this
    public auth URL." (registry member PUBLIC_URL_PLAIN_HTTP_REFUSED). The README now states which auth URLs
    are accepted, with a table of examples that a test checks against the
    code. See Consiliency/pmcp#341.

  • Auth operator messages come through pmcp's own sanitiser intact, and
    invalid auth configuration refuses startup.
    Three auth and startup
    messages were reworded because pmcp's own sanitiser rewrote them:
    Missing bearer token. is now Empty token., Unsupported token algorithm. is now The token's algorithm is not supported., and the
    shared-secret startup error shared-secret auth mode requires auth_token.
    is now auth_token is required when auth_mode is shared-secret.. Every
    fixed message pmcp.auth and the HTTP transport raise or send as an auth
    rejection -- error descriptions, 401/403/503 bodies, startup refusals --
    now comes from one registry, AuthMessage, and is rendered through a
    runtime guard that refuses anything else; a test checks each through the
    sanitiser. (Out of scope: the metadata diagnostics lists,
    UNVERIFIED_URL_CAVEAT, fetch_json_metadata's strings, the plain
    413/429/504 bodies and log templates.) Behaviour changes: with the HTTP
    transport in resource-server mode, PMCP now refuses to start on a JWKS
    URL, metadata URL or required scope that an auth message could not carry
    (a stray value in another mode is ignored, as before); and on the
    programmatic path, create_http_app refuses a protected-resource
    metadata URL that normalisation used to drop silently, omitting the
    metadata route -- any URL that is not an absolute https:// URL to a
    public IP address or a DNS name other than localhost, with its host in
    canonical form, no backslash or control character, and a valid port (the
    Consiliency/pmcp#341 entry above states the full rule)
    (so plain http:// to any host, loopback included, is refused too; see
    Consiliency/pmcp#341).
    The metadata route also refuses to
    start rather than publish an empty resource, which no shipped
    configuration reaches. The README now documents deployments behind a
    prefix-stripping proxy; the metadata 404 there is a known follow-up,
    Consiliency/pmcp#334.
    See Consiliency/pmcp#326.

  • A JWKS with no usable keys is a 503, not a 500. An issuer serving an
    empty key set, or one whose keys are all unusable, made PyJWKSet raise
    PyJWKSetError, which no handler caught. It now takes the existing
    key-set-unavailable path (503, error="temporarily_unavailable"); the
    response and logs carry no JWKS content and no token. See
    Consiliency/pmcp#320.

  • Every failed JWKS fetch is a shared 503, not a per-request 500. A
    failure other than the ones the fetch already mapped (for example a deeply
    nested JSON body raising RecursionError) escaped as a 500 and skipped the
    shared failure backoff, so each queued request fetched again. Any failure now
    opens the backoff and returns the key-set-unavailable 503. A cancelled fetch
    deliberately does not open the backoff, because the caller went away, not the
    endpoint; the next waiter fetches. See
    Consiliency/pmcp#231.

  • sanitize_auth_diagnostic does its keyword and URL-punctuation work in linear time. The keyword rule now runs through pmcp.keyword_matcher (the same matches as the regular expression it replaces, pinned by a seeded corpus), and trailing punctuation is split off a URL in one pass. Output is unchanged.

  • A downstream MCP server can no longer hang a caller by sending a request, being cancelled, or timing out — the remaining "the server hangs" runtime gaps are closed. A server→client JSON-RPC request (a frame carrying both method and id) is now answered rather than dropped: ping gets an empty result and any other method a -32601 "Method not found" refusal (the gateway advertises no client capabilities, so it does not forward an untrusted server's request to the agent), and classifying by method first also stops a downstream request whose id collides with one of ours from being misrouted as our response. _send_request no longer leaks a pending_requests entry when the caller is cancelled or the write itself raises — the entry is popped in a finally and a mid-write error still propagates. Cancellation is now propagated downstream as notifications/cancelled on gateway.cancel, on idle/ceiling timeout, and on caller cancellation (never for initialize, per spec; exactly once per cancellation). Replies and cancellation notifications go through a bounded per-server outbound queue drained by a single writer task that every teardown path cancels (_cleanup_client, disconnect_server, and a failed handshake on stdio or a remote transport; Consiliency/pmcp#287), so a downstream that stalls its own sink cannot make the gateway allocate unbounded tasks or buffer unbounded frames (review findings C-01, C-02, C-04). See Consiliency/pmcp#232.

  • A malformed downstream frame no longer drops the connection (Consiliency/pmcp#287). Malformed downstream frames no longer drop the connection, on any transport. Legacy SSE and streamable HTTP (including the server-pushed GET stream): the mcp library validates every incoming frame and, when validation fails, hands pmcp the ValidationError in place of the message; pmcp re-raised it, which ended the read loop, set the server to ERROR, failed every in-flight request with ConnectionError, triggered a reconnect, and logged the error text, which carries the frame's contents. That error is now dropped with a debug line naming only its type, and the loop keeps reading; any other exception on the stream is still a transport failure and still ends the loop. (The mcp library's own mcp.client.* loggers still log the rejected frame at ERROR; that is outside pmcp.) Stdio: pmcp parses frames itself, and the stdio and remote paths now share one frame dispatcher that drops, with a debug line naming only the offending type, a frame that parses as JSON but is not an object ([], 42, "x", null, true used to raise AttributeError and drop the connection); an id that is not a string, integer or null (a list or dict id raised TypeError in the pending lookup, and a true or 1.0 id resolved our request 1 by numeric equality); a method that is present but not a string (it fell through to the pending lookup, where a colliding id resolved our request); and a response to a request whose caller already gave up (InvalidStateError). A line that fails to parse without a JSONDecodeError (an integer over Python's digit limit raises ValueError, deep nesting raises RecursionError) is dropped the same way. Anything these checks miss is caught per frame and logged as a warning with only the exception type. The existing debug line for a non-JSON stdout line still echoes that line. See Consiliency/pmcp#232.

  • A non-ASCII Authorization header no longer turns any request into a 500. hmac.compare_digest raises TypeError on str containing non-ASCII, so an unauthenticated caller could crash any request with one header byte; the shared-secret comparison now happens on bytes and a bad header is simply unauthorized (review finding S-09). See Consiliency/pmcp#231.

  • A non-UTF-8 byte on a downstream server's stdout no longer drops the connection. The decode raised UnicodeDecodeError, which is not a json.JSONDecodeError, so it escaped that handler, exited the stdout read loop and marked the server unexpectedly disconnected — the "the server hangs" report. The line is now decoded with replacement characters and discarded as an ordinary parse failure, and a warning names the server when undecodable bytes arrive (review finding C-03). See Consiliency/pmcp#232.

  • The operator's policy locations are resolved when a PolicyManager is built, not when the module is imported. ~/.claude/gateway-policy.{yaml,json} were joined to Path.home() at import, so a HOME changed afterwards — the test suite's isolation, or a re-homed process — was ignored and the discovery search list could not follow it. They are now resolved at call time, while a monkeypatched USER_POLICY_PATHS / DEFAULT_POLICY_PATHS is still honoured verbatim, so the ungated-vs-gated decision is unchanged. See Consiliency/pmcp#262.

  • A hung gateway.update_server probe is reported as a timeout again on Python 3.10. _run_update_probe_command bounds the probe with asyncio.wait_for, whose asyncio.TimeoutError is not the builtin TimeoutError and not a subclass of it before 3.11 — so the caller's handler never fired and a real 60-second hang surfaced through the generic branch as Failed to run update probe: with an empty reason. The helper now normalises to the builtin before the exception reaches a caller (the contract ClientManager._send_request already provides), and the caller accepts either class. See Consiliency/pmcp#269.

  • The credential store is written atomically (temp file → fsync → os.replace), so an interrupted write_env_file no longer truncates the file and loses its other entries. See Consiliency/pmcp#248.

  • A symlinked pmcp.env is written through, as in 2.7.3 — the atomic write in this release no longer replaces it. Renaming the temp file over the path swapped a ~/.config/pmcp/pmcp.env symlinked from a dotfiles repository for a regular file, so pmcp secrets set and gateway.auth_connect stopped updating the dotfiles copy and the two drifted apart silently. Every whole-file rewrite of a user-owned PMCP file — pmcp.env, trust.json, package_approvals.json, the registry cache, and the client config pmcp setup --write emits — now goes through one helper that first asks the system to look the whole path up, so its own verdicts come first — the 40-link limit counted across the entire lookup, permissions, a non-directory on the way — and only a file not created yet lets the write go on; it then follows the final link chain (a chain, or a relative link) hop by hop through open directory handles, with no normalisation and no pathname longer than one link's own text, to find the name to replace (by joined pathname only where the platform has no directory handles, whose limit is then the system's maximum path length, or where, without O_PATH, a directory may be searched and written but not listed — every store reader, writer and residency check applies that one rule); it writes the temp file beside the target and replaces the target atomically at mode 0600, leaving the link untouched. A dangling link creates its target file when the target's directory exists; the helper never creates a missing target directory, though the directory of trust.json and package_approvals.json is created at 0700 as before; both stores are located the way the helper writes them, never by a lexical Path.resolve() (a link through missing/.. names nothing, as the system says; see #374). A link loop and a target ending in / are refused. A project .env.pmcp is a path the repository controls, so pmcp follows no symlink there at all: the store, and every directory between the project root and it, is opened without following links and must not be one, and the store must be a regular file or absent; the write happens in the directory that walk opened, so the path checked is the path written (on Windows the same rules run over pathnames). A --project path is kept as given and resolved by the system at each use; one that does not exist yet may only be a plain tail of new directories, which are then created, and any other unresolvable --project is refused. These guarantees are about what a repository ships — links, directories and file types present before the command runs. A process already running as you and rewriting the store's directory while the command runs is out of scope (it can write your files directly); against it, a read checks that the file it opened is the one it checked, and the temporary file's name is re-checked before the rename, as a best-effort narrowing only. Every READ of a project .env.pmcp on the way to a rewrite goes through that walk too: pmcp secrets set, pmcp secrets sync (its source and its target) and gateway.auth_connect, and the load of the served project's .env.pmcp that every pmcp command runs at startup, before the subcommand; a symlinked or non-regular store is refused before anything is read (at startup: skipped with one pmcp: refusing to load .env.pmcp: … line on stderr). A store that is not UTF-8, or holds a value with a newline, is reported as "ok": false by secrets set and sync instead of raising. Every other reader of a project .env.pmcp — a running gateway's spawn-time credential checks and env stripping, remote-header auth, and pmcp secrets check — reads it through the same walk and never blocks on a fifo: a refused store reads as empty; the startup load of a plain .env found outside your home directory is read the same way (#367). A package_approvals.json whose link chain lands inside the checkout being judged is refused, as trust.json already was; both checks compare file identity, not path strings, walking up from the store's directory by open directory handles (on Windows, by the strictly resolved directory's parents), skip a served project root that does not exist yet, and refuse the store when residency cannot be established. A store the system refuses to look up (too many links in one lookup, no permission) is an error on every read and write path, never an empty store; the temporary file is created in the directory the system resolves for the target, never one a library normalised. The startup-policy editor still refuses a symlinked project .mcp.json. See Consiliency/pmcp#248.

  • The npm version-check User-Agent now names github.com/Consiliency/pmcp instead of the pre-rename ViperJuice/pmcp. See Consiliency/pmcp#247.

  • The default feedback repository was ViperJuice/pmcp, a repository this
    project does not own.
    Every unconfigured gateway that submitted feedback — or
    merely previewed it — named that repository in its output and in the browser URL
    it handed the agent to open. The packaged default is now
    Consiliency/pmcp, the project's real remote, asserted in the tests against the
    distribution's own Project-URL: Repository metadata rather than against a
    duplicated literal, so the same drift cannot recur silently. See
    Consiliency/pmcp#230.

  • A feedback submission no longer blocks the gateway's event loop, and it is
    bounded.
    Both HTTP calls used to run inline inside the async handler, so for as
    long as they took — up to 15 s of socket timeouts, and unbounded in the worst case
    — the gateway served no other downstream call. The submission now runs in a worker
    thread under a 20 s end-to-end bound, with per-phase budgets that sum below it so
    nothing is started that cannot finish inside it. A per-socket timeout is not a
    request bound — it restarts on every read, so a peer trickling one byte at a time
    holds a call open forever — which is why the bound is on the act and not only on
    the answer: the worker asks for permission to send as the last step before it
    opens the socket, and once the handler has given up that permission is refused, so
    an operator is never told nothing was sent by a gateway that then sends. The
    repository-visibility probe now runs after a created issue instead of before the
    post, and a preview no longer claims repository_visibility: "public" on a path
    that made no request; it reports "unknown", which is what it knows. See
    Consiliency/pmcp#230.

  • Downstream failures no longer log unhandled errors in a TaskGroup and
    nothing else.
    Every remote-transport path in ClientManager runs inside an
    anyio task group, and str(ExceptionGroup) names neither the type nor the
    message of what actually failed — so twenty log sites reported only that
    string for any failure. A week of CI hangs
    (Consiliency/pmcp#200) produced exactly it,
    which is why the cause stayed unknown. describe_exception() flattens a
    group to its leaf exceptions (ConnectionResetError: peer went away), and is
    used at every site that logs a caught exception, including
    disconnect_server's returned error string. Detection is by duck-typing
    .exceptions, because 3.11+ raises the builtin BaseExceptionGroup while
    3.10 raises exceptiongroup.ExceptionGroup from the backport. The rendered
    text goes through sanitize_auth_diagnostic, so flattening cannot widen
    secret exposure — most of these sites logged the raw exception before and are
    redacted now. last_error (surfaced by pmcp status, pmcp doctor and
    health output) and the error strings connect_server and disconnect_server
    return to their callers were rendering the group string too, and are fixed as
    well. An AST guard over pmcp.client.manager fails CI if a caught exception is
    interpolated into an f-string or passed to str() anywhere inside its handler — not merely inside
    a logger call, which was the guard's first, too-narrow form. The one
    exc_info= call site in ClientManager is gone too: exc_info hands the raw exception to the
    logging machinery, which appends the unredacted exception tree after the
    sanitized message, so a bearer token in a transport error reached the log in
    full. The traceback is now formatted in-process and sanitized, keeping the
    frames. See
    Consiliency/pmcp#224.

Changed

  • Gateway tool inputSchemas are now derived from the pydantic models that validate the arguments, so the two can no longer disagree (Consiliency/pmcp#236). Constraints the models always enforced are now advertised and enforced at the transport gate — minLength on identifiers, submit_feedback.title 8–160 chars, bounds on tasks_result.options — so those rejections now come back as an isError tool result reading Input validation error: … instead of an {"error": true} payload. gateway.invoke now advertises task, trace_context and _meta; gateway.tasks_* advertise requestor_context; tasks_result.options gains timeout_ms. Optional arguments are advertised as anyOf: [X, {"type": "null"}] and the transport gate now accepts an explicit null for them, as the handlers always did; 28 optional arguments (e.g. catalog_search.query, invoke.options, auth_connect.credential) were previously rejected at the gate when sent as null. null is never an enum value or part of a type array, because host schema converters do not carry those over: OpenCode's Google provider turns an enum value null into the string "null" and drops the null from a type array. That provider sends the anyOf form to Gemini as {"type": X, "nullable": true}; Claude Code and Codex pass it through unchanged. A client that reads a single flat type per argument now finds anyOf on these arguments (Consiliency/pmcp#369). The gate does not apply pydantic's lax coercion: values such as 1 for a boolean or "5" for an integer on the newly advertised invoke.task fields (enabled, ttl, poll_interval), which were previously accepted and coerced, are now rejected with Input validation error: 1 is not of type 'boolean'. invoke.task.ttl and invoke.task.poll_interval are now range-checked at the gate; the ranges are given in the Consiliency/pmcp#298 entry below. invoke.evidence_label_digest now also advertises its exact length (64), so a digest with a trailing newline is rejected at the gate instead of by the handler. Inputs the gate now rejects that previously reached the handler were recorded in the scoped-advisor audit as failure; they are now recorded as audit.rejection events with terminal_status: "invalid_arguments" (see the Consiliency/pmcp#296 entry below). Unknown keys are still accepted and ignored, as before; forbidding them is tracked on Consiliency/pmcp#236. Argument descriptions agents already saw were unchanged by this entry, except gateway.update_server.force, which now describes the task-aware behaviour; 19 previously undescribed arguments gain a description. (Several more descriptions are corrected later in this release; see the agent-visible text entry under Changed.)
  • tools/call input-schema rejections are now recorded in the scoped-advisor audit, without argument values (Consiliency/pmcp#296). A call the transport gate rejects used to return Input validation error: … before the audit was reached, so an operator saw no attempt at all. It is now written as a new audit.rejection event (not an audit.invocation: nothing was invoked, and a reader that correlates invocations to a run skips it) with the tool name (for the scoped-advisor tools; any other tool is recorded with gateway_tool: null and a gateway_tool_digest), terminal_status: "invalid_arguments", rejected_argument_path, the failing location as a JSON array (a key the schema declares, an array index, or null for a key the caller chose, since that key can itself be a secret), and rejected_argument_validator, the failing JSON Schema keyword (type, pattern, required, …). The record never contains the validation message, the rejected value, correlation IDs, or any digest of the arguments. The capability stays scoped_advisor_audit.v1; readers that dispatch on event are unaffected. Policy is now judged before the schema: a call to a policy-blocked gateway tool is refused with "Gateway tool blocked by policy" and recorded denied whatever its arguments, instead of getting an Input validation error that described the blocked tool's schema. If the audit sink has failed, a malformed call now gets "Scoped advisor audit channel failed" like every other call, instead of its validation error. The response to a rejected call from an allowed tool is unchanged. An audit.invocation record now reads nothing the schema gate did not vouch for: a call refused by policy, or made to an unregistered name, is recorded denied with every argument-derived field (run_correlation_id, seat_correlation_id, downstream_tool_id, evidence_label_digest, source_reference_hash) null, a result digest that no longer covers the caller's tool name, and a gateway_tool_digest of the registered name (for an unregistered name, of nothing) — previously a correlation-shaped value or a public URL anywhere in such a call's arguments was copied or hashed into the audit. Every other invocation record reads only the top-level arguments the tool's schema declares, so a correlation-shaped key a tool does not declare (e.g. run_correlation_id on gateway.describe) is no longer recorded; gateway.invoke declares every field the record reads, so its records are unchanged.
  • gateway.invoke's task.ttl and task.poll_interval are bounded, and
    NaN/Infinity are refused at the gate (see Consiliency/pmcp#298).
    • task.ttl must be an integer from 1 to 9,007,199,254,740 seconds, so that
      it is at most 2^53−1 ms once Consiliency/pmcp#330 converts it (next
      entry). Zero and negative values, which were forwarded downstream unchanged,
      are now rejected with Input validation error: …, and so is any value above
      the maximum.

    • task.poll_interval must be a finite number greater than 0 and at most
      9,007,199,254,740. Zero, negative values, NaN, Infinity and -Infinity
      are now rejected; they were previously accepted and forwarded.

    • The transport gate now treats NaN and ±Infinity as non-numbers for
      every numeric argument. Both transports can deliver them, even though they
      are not JSON. Until Consiliency/pmcp#297 lands, a rejection message may
      repeat the caller's own number back to that caller, as every numeric bound
      already did.

    • A downstream task's ttl, pollInterval, createdAt,
      lastUpdatedAt/updatedAt or status that pmcp cannot use is now
      reported as null, and the field is named in the task's new
      unusable_fields array, using pmcp's names (status, created_at,
      updated_at, ttl, poll_interval). "Cannot use" covers:

      • non-finite numbers and out-of-range values;
      • a boolean;
      • a string for ttl or pollInterval (a numeric or ISO 8601 string is
        still accepted for createdAt and lastUpdatedAt/updatedAt);
      • a non-string or blank status;
      • a blank timestamp;
      • a null the downstream sent.

      Before, such a value was coerced ("5" became 5, true became 1),
      stored as given, or it failed the call after the downstream had already
      created the task. A null ttl that is not listed in unusable_fields
      keeps its MCP meaning: unlimited. When a field arrives under more than one
      name (updatedAt/lastUpdatedAt, createdAt/created_at,
      pollInterval/poll_interval), the first usable one wins, so a null
      updatedAt no longer hides a good lastUpdatedAt. The task's raw holds
      what the downstream sent, except that non-finite numbers appear there as
      null. A task whose lastUpdatedAt was unusable now reports
      updated_at: null instead of the time pmcp recorded it. A downstream that
      leaves the field out entirely still gets pmcp's observation time, as
      before.

    • Over HTTP and SSE, downstream replies are now read as the server sent
      them: NaN, Infinity and 1e400 used to arrive as null. This has two
      side effects, both matching what stdio servers already got:

      • A nextCursor of NaN now makes that listing unreadable. pmcp keeps
        the previous tools/resources/prompts and publishes no change, instead of
        treating page one as the whole listing.
      • A NaN in a downstream value that pmcp relays as sent now reaches the
        caller as a NaN token instead of null.
        That is a tool result
        (gateway.invoke) and a task result (gateway.tasks_result), not only
        the first. A task's raw still shows null, and resource and prompt
        reads pass on only their text. Fixing that for every transport is
        tracked as Consiliency/pmcp#335.
    • Finished tasks past the 100-record cap are now evicted in the order pmcp
      last recorded them. A downstream's own timestamps play no part: a
      far-future lastUpdatedAt can no longer keep one server's tasks while
      another's are dropped, and a server whose clock runs behind no longer
      loses its tasks first. Consiliency/pmcp#338 then made the cap per server
      (next entry).

    • pmcp no longer sends a downstream server anything that is not strict JSON:
      NaN, ±Infinity, or a value JSON cannot encode. A request whose
      arguments or task metadata contain one now fails with outbound frame is not strict JSON. A fire-and-forget frame (a reply or a notification pmcp
      originates) that contains one is dropped and logged, never written. Before,
      stdio servers received a non-JSON NaN literal, and HTTP/SSE servers
      silently received null.

    • pmcp documented ttl and poll_interval in seconds, but MCP defines both
      in milliseconds, and pmcp forwarded them unchanged. The next entry
      (Consiliency/pmcp#330) fixes that.

  • Task ttl and poll_interval are now converted between pmcp's seconds and
    MCP's milliseconds (see Consiliency/pmcp#330).
    pmcp has always documented
    gateway.invoke's task.ttl and task.poll_interval in seconds. MCP
    2025-11-25 defines ttl and pollInterval in milliseconds, and pmcp passed
    the number through unchanged. So task: {ttl: 300}, meant as five minutes,
    gave a spec-conforming server a 300 ms retention, and its task was gone
    0.3 s later.
    • If you worked around this by sending milliseconds, your values are now
      1000× too long.
      task: {ttl: 300000} used to mean five minutes to a
      spec-conforming server. It now asks for 300,000 seconds, about 3.5 days.
      Send seconds instead: ttl: 300. The same applies to poll_interval.
    • If you run a tenant server built to pmcp's earlier tenant contract,
      which described ttl in seconds, it now receives milliseconds: a caller's
      ttl: 300 arrives as ttl: 300000. A server that reads that as seconds
      keeps the task 1000× longer than asked. It must also return ttl and
      pollInterval (or poll_interval) in milliseconds, or pmcp reports them
      1000× too small: a returned ttl: 300 is shown as 0.3 seconds. See
      specs/tenant-code-mode-host-contract.md.
    • Outbound: task.ttl is sent as ttl in milliseconds (seconds × 1000,
      exact). task.poll_interval is sent as pollInterval × 1000. MCP's
      TaskMetadata has no pollInterval, so a spec-conforming server ignores
      it.
    • Inbound: a downstream task's ttl and pollInterval are read in
      milliseconds, and so is the snake_case poll_interval alias some servers
      send. When both poll aliases are present, pollInterval wins if it is
      usable after conversion to seconds; otherwise a usable poll_interval
      does. pmcp reports and records them as ttl and poll_interval
      in seconds, everywhere a task is returned: gateway.invoke's task,
      gateway.tasks_list, gateway.tasks_get, gateway.tasks_result and
      gateway.tasks_cancel. A task that used to show ttl: 300000 now shows
      ttl: 300.0. ttl is now a number that may be fractional, not an
      integer: 1500 ms is reported as 1.5. A ttl of null still means
      unlimited.
    • Bounds: the caller's maximum for both fields is now 9,007,199,254,740
      seconds, so that the milliseconds pmcp sends stay within 2^53−1. Larger
      values, which the previous entry accepted up to 2^53−1, are rejected
      with Input validation error: …. A downstream value is checked as sent,
      in milliseconds, under the previous entry's rules: ttl must be an
      integer from 0 to 2^63−1, and pollInterval a finite number greater than
      0. Only then is it converted. A pollInterval so small that it divides to
      0 seconds is reported as unusable.
    • gateway.invoke now recognises a task the downstream returns at the top
      level of its tools/call reply ({"taskId": …}), the same way pmcp
      already recorded it. Before, invoke looked only for the wrapped
      {"task": …} form, so for that shape it returned task: null, relayed the
      reply as result in milliseconds, and skipped the default redaction
      applied to task replies. A call that did not run as a task never reports a
      task, even if its result has a taskId.
    • Unchanged: a task's raw object, and the results pmcp relays as sent,
      keep the downstream's own milliseconds.
  • Tracked MCP tasks are capped per server, and the cap now covers
    unfinished tasks (see Consiliency/pmcp#338).
    • pmcp tracks at most 100 tasks per downstream server, finished and
      unfinished alike, and at most 1000 across all servers. Before, one
      100-record cap was shared by every server and counted only finished
      tasks. So a server whose tasks a caller listed could evict another
      server's finished tasks, and a server could keep any number of records
      alive by reporting them as working, or with a status pmcp cannot use.
    • Past a server's cap, that server gives up its own records: finished ones
      first, then unfinished ones, each in the order pmcp last recorded them.
      Past the total, the server holding the most records gives one up.
      Recording a task never evicts that same task, so gateway.invoke keeps
      the record of the task it just started. A listing is recorded one task at
      a time, so a tasks/list reply longer than the cap keeps only its last
      100 tasks.
    • Eviction forgets pmcp's record, not the downstream's task.
      gateway.tasks_get and gateway.tasks_result still reach it and track
      it again. Until then:
      • gateway.tasks_cancel reports it as not found;
      • it does not count as an active task;
      • a disconnect neither waits for it nor cancels it. A stdio server's task
        ends with its process. A remote server's task keeps running after
        the disconnect, until the downstream ends it;
      • a requestor_context pmcp stored with it is forgotten, so send it
        again.
    • gateway.tasks_list still returns every task the downstream listed,
      even past the cap.
    • A task that a request is working on is never evicted while that request
      is in flight: gateway.tasks_get, gateway.tasks_result,
      gateway.tasks_cancel, and the one tasks/cancel a forced teardown has
      in flight. While such requests are in flight, a server may hold more than
      100 records: at most one more than the requests in flight at that moment,
      and it is back under its cap as soon as they finish. The 1000 total works
      the same way.
    • A forced gateway.disconnect_server, gateway.restart_server or
      gateway.refresh cancels every task that was active on the server when
      it was requested, with each task's newest requestor_context, so a
      remote downstream still gets it. What it owes is fixed when it starts: a
      task the cap evicts while the teardown works through the others is still
      cancelled, and still with its context. Those tasks do not count against
      the caps, so overlapping forced disconnects cannot keep growing a server
      past its cap, but they still count as the server's active tasks: another
      forced teardown cancels them too, a disconnect without force is refused
      while any remains, and gateway.refresh counts them. A teardown that is
      interrupted -- its caller cancelled, a downstream error, or a forced
      disconnect of another server -- puts every task it did not cancel back
      among the tracked ones, so a retry cancels them. They come back as the
      server's newest tasks and under its cap again: when they and the
      server's other tasks exceed it, the cap gives up, in its usual order,
      the server's finished tasks, then its older unfinished ones, then the
      oldest returned ones: a task pmcp owed a cancel outranks newer tasks it
      never owed. A forced disconnect that is
      itself interrupted still disconnects
      (Consiliency/pmcp#324) without the
      cancels it had not sent, so a remote server's remaining tasks keep
      running. Eviction never fails the teardown.
      One kind of snapshot task is skipped: one whose original connection
      cannot take a task request at that moment. That covers four cases: a
      concurrent disconnect removed the server, a reconnect has it listed but
      not yet connected, a reconnect replaced it with a new connection, or it
      does not advertise task support. No tasks/cancel is sent for such a
      task, and it is not an error. On a
      remote server that is reconnecting, the task is not cancelled.
      gateway.disconnect_server and gateway.restart_server still include it
      in cancelled_task_count. That field counts the active tasks pmcp no
      longer tracks after the call (before minus after), as it always has.
      Reporting the cancels actually sent is a separate change (see
      Consiliency/pmcp#349).
    • When pmcp records a downstream reply about a task (tasks/get,
      tasks/result, tasks/cancel), the task keeps the requestor_context
      pmcp holds for it. That is the newest one: if gateway.invoke supplied a
      newer context for the same task while the request was in flight, the
      reply keeps that one. A later cancel sends it. A reply that names a
      different task does not take this task's context.
    • Task state is bound to the connection it came from. A reconnect drops the
      server's tracked tasks, as before. A reply that arrives on the old
      connection after a reconnect is no longer recorded. pmcp never sends a
      stored task id or requestor_context on a connection other than the one
      the task came from, so a reconnect to another URL or tenant cannot
      receive the old tenant's context, or cancel an unrelated task that has
      the same id. For a record from a previous connection,
      gateway.tasks_get, gateway.tasks_result and gateway.tasks_cancel
      fail with Server <name> was reconnected; this task belongs to its previous connection, and a forced teardown skips it. gateway.invoke,
      gateway.tasks_result and gateway.tasks_list report the task the way
      this reply describes it. They never report a same-id task that a new
      connection tracks, which could belong to another tenant. They also never
      merge its created_at, tool_id or requestor_context. Each request
      uses one connection, captured when it starts. If that connection is
      replaced while tasks/result is in flight, its tasks/get fallback is
      not sent anywhere else: gateway.tasks_result returns the result with
      no task. gateway.invoke
      still redacts by default when the call requested a task and the reply
      carries one.
    • A forced gateway.refresh that cannot disconnect a server now says so.
      It returns ok: false with
      Server '<name>' could not be disconnected: <reason>. Before, it
      reported success, and the server stayed connected even if it had been
      removed from the config or denied by policy.
  • pmcp config set-startup-policy add|remove|set --source project --apply now carries your prior trust approval forward when it rewrites .mcp.json; a symlinked .mcp.json is refused for every source (user, project and custom, apply or preview, CLI or gateway.set_startup_policy). Setting the startup policy changes the file's bytes, and trust approval is content-keyed, so the edit used to silently invalidate your own pmcp trust approve of that file and the next startup refused it. When the pre-write bytes were approved, pmcp now re-records the approval for the exact bytes it writes — keyed on the opened descriptor's verified identity (the resolved key must name the same file the descriptor holds open), never re-approving a file that was not already approved, and never approving a substituted file. A target swapped or unlinked mid-operation is refused rather than mis-bound, and on POSIX a symlinked .mcp.json is refused up front. If re-recording ever fails because the trust store is unusable, the edit is still written and the failure is surfaced as a diagnostic rather than crashing (an unusable store also fails the approval check, so nothing is silently carried forward). See Consiliency/pmcp#253.
  • Every install spawn now logs the command it runs, at WARNING, before it
    runs.
    start_install, the legacy install_server and verify_installation
    each log a rendered command line immediately before the subprocess is
    created, and still log it if the spawn then fails (for example
    FileNotFoundError). Two other spawns that fetch and run a package log the
    same way: starting a stdio server whose command is a package runner (npx,
    uvx, pnpx or bunx, recognised under Windows and POSIX spellings alike:
    any directory, either path separator, a drive prefix, any letter case and one
    .exe, .cmd or .bat suffix; any other command logs no warning), and every
    gateway.update_server probe. Previously start_install logged
    <args redacted> at INFO, which hid exactly which package ran, and
    install_server logged the full argv verbatim at INFO, including any
    credential it carried.
    The rendering is secret-safe by construction rather than by guessing what a
    secret looks like. It shows the executable, the flag literals -y, --yes and
    --quiet wherever they appear, --registry by name (never its value, which
    can carry user:password@), and the package argument only when it is a pinned
    name@version. Every other argument is <redacted>. The package argument is
    found by its position, not by its shape, because a bare token is
    indistinguishable from a package name. Expect <redacted> in the package
    position for manifest installs
    : the shipped manifest's install commands are
    not pinned to exact versions (for example @playwright/mcp@latest), so a
    manifest install shows its package only when you pin it with version: or
    server_version:; a discovered server's spawn, which is always pinned, shows it. See
    Consiliency/pmcp#230.
  • Every GitHub Action is pinned to a commit SHA. Every remote uses:
    reference in the five workflows and in the local composite action
    .github/actions/pipeline-bootstrap-setup now reads
    owner/action@<40-hex-sha> # vX.Y.Z. Every pin is the commit its previous
    mutable ref resolved to on the day, so no action runs a different version
    after this change
    : the PyPI publish action is pinned to v1.14.2, the commit
    release/v1 already pointed at, and the composite's setup-node@v4 to
    v4.4.0, the commit v4 pointed at. scripts/check_workflows.py gains a
    raw-text invariant that fails CI on any remote uses: not in that form —
    in workflows and in local actions, .yml and .yaml alike, and cross-checks
    that inventory against the parsed document so a uses: spelled in a form the
    line scan cannot see ("uses":, {uses: …}, a block scalar) fails rather than
    runs unpinned — and its exact
    allowlist for release.yml now names commits, so a form-valid pin to the
    wrong commit (a moved digit, or a real older release with an honest comment)
    is rejected too. Dependabot gains an entry for the composite action's
    directory, which its root entry had never scanned. Six new mutants prove
    each of those catches (.consiliency/evidence/mutation-217.md); see
    Consiliency/pmcp#217.
  • CI: actions/setup-node v4 → v7 in the workflows (Dependabot, Consiliency/pmcp#216).
  • CI: pinned actions moved by Dependabot — the composite's actions/setup-node
    v4.4.0 → v7.0.0 (Consiliency/pmcp#219), actions/download-artifact v7.0.0 → v8.0.1 on the
    release path (Consiliency/pmcp#221; hash mismatches on download now error instead of warn),
    astral-sh/setup-uv v7.6.0 → v10.0.1 (Consiliency/pmcp#220) → v10.1.0 (Consiliency/pmcp#245) → v10.2.0 (Consiliency/pmcp#313),
    docker/build-push-action v7.3.0 → v7.4.0 (Consiliency/pmcp#275) and docker/setup-buildx-action
    v4.3.0 → v4.4.1 (Consiliency/pmcp#276). Each release-path bump carries its EXPECTED_USES update in
    the same PR.
  • Test harness (contributors only). pytest-timeout is now in the dev extra:
    a test that runs past 700 s fails with a thread dump (timeout_method = "signal"),
    and faulthandler is a last resort at 720 s that dumps and exits the process, so a
    hung CI job is a red X with a stack rather than a silent cancel; the CI test job cap
    is 30 minutes (Consiliency/pmcp#200). An autouse fixture runs every test from a
    private directory under pytest's temp root (opt out with the new real_cwd marker),
    and the session stops with one named error if that temp root's ancestors contain a
    package.json or node_modules. Process-global caches (the registry's in-process
    cache, the HTTP rate-limit buckets and request metrics) are reset between tests
    through test-only seams that production never calls (Consiliency/pmcp#235,
    Consiliency/pmcp#261). The suite no longer asserts on wall-clock windows:
    tests/_timing.py (eventually, eventually_sync, Rendezvous) is the one
    shared wait primitive, eight private pollers and the fixed sleep-then-assert
    sites now wait for the property, the elapsed < X assertions are replaced by
    the property each stood in for (fixing the Consiliency/pmcp#226 flake), and the
    idle-timeout tests drive an injected request clock: ClientManager takes
    clock= (wall time by default, so started_at is still an epoch) and caps the
    idle re-check at IDLE_POLL_SLICE_S.
  • Shipped code snippets are always valid Python. gateway.describe's L2 snippets are cut to max_snippet_lines (default 4); four try/except templates lost their except body at that budget and were invalid. The loader now returns the longest prefix that parses (or no snippet), and those four templates now show the real pattern: gateway.invoke and gateway.provision report failure as ok: false, not by raising. Six more snippets misread the gateway's responses and are fixed: the GitHub, Postgres, SQLite and filesystem filters now unwrap result (the downstream tools/call result) before filtering, gateway.describe's example checks annotations/safety_notes (it never returned risk_hint), and provision_status is polled by job_id until its status leaves pending/installing. Every snippet is now executed in tests against responses built from the gateway's own output models.
  • Agent-visible data and help text. The try/catch code hint is now try: at the
    default max_hint_length of 8 it was being cut to try/catc. The Playwright
    screenshot entries in code_patterns.yaml and code_examples.yaml now name the
    server's real tool, browser_take_screenshot, with its filename argument. Gateway
    tool and argument descriptions and the CLI help are clearer; the pmcp secrets set
    example no longer puts the secret on the command line, and pmcp refresh --help
    documents that its default cache directory differs from the one the gateway reads
    (Consiliency/pmcp#352).
  • load_manifest() is cached, and pmcp's own manifest is parsed with libyaml. (CSafeLoader, when PyYAML
    was built with it; otherwise the pure-Python SafeLoader as before.)
    Each call re-parsed the 78 KB shipped manifest with PyYAML's pure-Python loader, up to
    13 times per gateway.catalog_search: ~1.6 s of event-loop blocking per search, measured.
    The result is now cached, keyed by the bytes of the shipped manifest and of every
    overlay, each project overlay's consent decision, the PMCP_MANIFEST_PATH redirect and
    its provenance, and the platform. Editing an overlay, approving or revoking one, or
    changing the env takes effect on the next call, as before. Warnings about a manifest
    are now logged once each time its inputs change (including a change back to an earlier
    state), not on every load. A file that cannot be read or parsed is still reported on
    every load until it is fixed. If the cache itself fails (it should not), pmcp logs one
    warning naming the error class and keeps loading without it. Overlays are still parsed
    with the pure-Python loader. The review's P-02 item (the lifecycle lock held across
    connect retry sleeps) is not part of this change and remains open on the same issue. See
    Consiliency/pmcp#233.