Repository navigation
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.yamlare ignored (with a WARNING naming the command) until
you runpmcp 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/--policypaths are unaffected. Security PMCP_MANIFEST_PATH,PMCP_CONFIGandPMCP_POLICYmust be exported in your
shell. A value that came from.env,.env.pmcpor~/.config/pmcp/pmcp.envis
ignored as if unset. Security- Spawned servers no longer inherit the keys pmcp loaded from
.env, except a
server's own declaredenv_var. Shell-exported variables are still inherited. An
install spawn underpmcp --project <dir>run from another directory no longer
inherits a credential stored in<dir>/.env.pmcp. Security - A symlinked project
.env.pmcpis 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 projectandgateway.auth_connectwithscope="project"
reportrefusing to write .env.pmcp: it is a symlinkand write nothing; 2.7.3 wrote the
secrets wherever the link pointed, which a cloned repository chooses. Everypmcp
command also stops LOADING such a store at startup: run from that directory, it prints
pmcp: refusing to load .env.pmcp: it is a symlinkon 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--projectpath the system cannot resolve) is reported the same way instead of
crashing the command. A source store thatpmcp secrets synconly 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,
printingpmcp: 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.envits startup walk finds outside your home directory and
its ancestors), and a tenantpmcp.envno 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 thepmcp secrets
commands still find them. Nothing else does — not pmcp's own settings
(PMCP_LOG_LEVEL,PMCP_PORT, …), notHOME, a proxy or a CA bundle, and not a
child process pmcp starts. Such a variable printspmcp: 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 withpmcp: Ignoring LEAK in .env.pmcp: its value refers to a variable the file does not define, …. Your shell
and~/.config/pmcp/pmcp.envnow 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.envbefore its.env.pmcp, never another project's, and rereads a
project file that changed. The project is the one pmcp serves ----projectwhen
given, otherwise the project root found from the working directory (the root
.mcp.jsonis loaded from) -- sopmcp --project Bstarted 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.yamloverlay (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 initwithout--projectwrites the served project's.mcp.json.
Started from a subdirectory of a
project, pmcp reads the root's.env.pmcp, the onepmcp 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
.envwon over the user store andpmcp secrets checkpreferred 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.pmcpthat lists a name, such as
NO_PROXYorSSL_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--projectis the served
project's.pmcp secrets sync --from-scope project --to-scope usercopies 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 namedPMCP_*, proxies, code-loading and
package-manager names) and any value containing${or spanning lines, withpmcp: 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_connectrefuses the
same names and a credential containing${.pmcp secrets checkanswers 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(USERPROFILEon 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 printspmcp: 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-diroutside 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 whateverHOMEnamed. Security - Discovered servers are default-deny.
gateway.register_discovered_serverresolves
and pins the package (and refuses one it cannot pin, or anenv_varsname that is not
credential-shaped);provision,connect_serverandrestart_serverrefuse it until
pmcp trust approve-package <name>@<version>or apackages.allowlistmatch; and
gateway.update_serverrefuses every discovered server. Security - New
packages:policy section. Apackages.denylistentry 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
aspkg@1.2.3makes the policy file invalid. Added, Security - Feedback submission is off by default.
gateway.submit_feedbackposts only after
pmcp guidance --feedback-submission onand with a shell-exported
PMCP_FEEDBACK_TOKEN;confirm_submission=truealone now returns a preview.
GITHUB_TOKEN,GH_TOKENand thegh issue createfallback are gone, a
PMCP_FEEDBACK_REPOfrom a checkout's.envis refused, the default repository is
Consiliency/pmcp, a preview reportsrepository_visibility: "unknown", and the
output gainssubmission_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, andcreate_http_apprefuses 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
unknownkidrefetches the JWKS at most once per 10 s, and any JWKS failure, including
a key set with no usable keys, is a503instead of a500. The metadata route's
resourceis--oauth-audience, or the metadata URL's origin plus/mcp, and no
longer the requestHost. A forged token pairing an algorithm with a key of
another type, and a non-ASCIIAuthorizationheader, get a401instead of a
500. Security, Fixed - The
tools/callgate enforces the schemas pmcp advertises. Constraints the
argument models always had are now rejected at the gate as anisError
Input validation error: …result instead of an{"error": true}payload. Lax
coercion (1for a boolean,"5"for an integer) oninvoke.taskis refused, and
an explicitnullfor an optional argument is now accepted. Policy is judged before
the schema, and gate rejections are recorded asaudit.rejectionevents, 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.invocationrecord for a call refused by policy or made to an unregistered
name isdeniedwith every argument-derived field (run_correlation_idand 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.ttlmust be an integer from 1 to
9,007,199,254,740 (seconds), andinvoke.task.poll_intervala finite number above 0
and at most 9,007,199,254,740.NaNand
±Infinityare refused for every numeric argument, and a request carrying a value
that is not strict JSON fails withoutbound frame is not strict JSON. A downstream
task field pmcp cannot use is reported asnulland named inunusable_fields.
Finished tasks past the 100-record cap are evicted in the order pmcp recorded
them, not by the downstream's timestamps. Changed - Task
ttlandpoll_intervalare 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_intervalin milliseconds. A task'sttlis now a
fractional number of seconds. Changed - Redaction removes more.
sanitize_auth_diagnostic,PolicyManager.redact_secrets
andprocess_outputnow 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_serverdoes not move a pinned server, and
pmcp updateprints[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], anargslist holding a
number, acommandortransportthat is not a string, acli_alternatives
entry with an emptycheck_command) is skipped
when the overlay is read, with a WARNING naming the field but not its value. 2.7.3
loaded it, and thengateway.catalog_searchfailed for every query, or startup and
gateway.refreshfailed 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 (pinggets 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 thehttpextra,starlette>=1.3.1(was>=0.27.0); thedevextra needs
pytest>=9.0.3(was>=7.0) and addspytest-timeout>=2.3. Security - Agent-facing hints. The
try/catchcode hint is nowtry, and the Playwright
screenshot pattern and example namebrowser_take_screenshotwithfilename.
Changed - A symlinked
.mcp.jsonis no longer edited.pmcp config set-startup-policy
and thegateway.set_startup_policytool refuse to rewrite a.mcp.jsonthat is a
symlink -- for the user, project and custom sources alike, and in a dry-run preview
too -- reportingsymlinked_config/invalid_source. 2.7.3 accepted the edit and
replaced your symlink with a regular file. Edit the link's target directly. Changed NaNfrom HTTP/SSE servers. Downstream replies over HTTP and SSE are read as
sent: aNaNinside a tool result now reaches the caller as aNaNtoken rather
thannull(as stdio servers already did; tracked as
Consiliency/pmcp#335), and a
nextCursorofNaNleaves 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 ofunhandled errors in a TaskGroup. One line still prints the old string: the batch-connect
Failed to connect to <server>: …, also shown aspmcp's "cannot reach PMCP
gateway" error; the WARNING lines before it name the cause. Match on the
underlying error instead. A hunggateway.update_serverprobe on Python 3.10
reportsUpdate 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-dirthat is itself a link (a symlinked~/.pmcpis 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 refreshwrites its cache to.pmcpby 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'sserver_version: {<server>: <version>}, holds an npx-launched server at one
exact version. pmcp writes the pin into the server'sargsand every platform's
installargv, 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 barenpx, 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_serverdoes not move a pinned
server, andpmcp updatereports 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_versionsays any earlier pin stands unless aservers: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'sserver_versionor a later source pins it.pmcp updateprints
[FAILED] … Could not determine a registry packagefor a pinned server, and moves
nothing, when the entry sets an npm setting, when the gateway's own environment sets
anynpm_config_*variable orNODE_OPTIONS, or when the directory the gateway runs
in, or any directory above it, contains apackage.jsonor anode_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_submissionkey 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 mirrorspmcp guidance --telemetry on|off: it writes
guidance.enable_feedback_submissioninto~/.claude/gateway-guidance.yaml—
user-scoped and outside any checkout, so a repository cannot ship its own consent —
andpmcp guidancenow printsFeedback Submission: ✓/✗beside the telemetry
line.offwrites an explicitfalserather 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=trueno 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_feedbackreturns 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_feedbackoutput gainssubmission_outcome. Optional, absent
(null) on every preview and on every refusal made before a request — so no
existing field changes and no existing reading ofsubmittedbreaks. When pmcp did
attempt a post it is one ofcreated,refused,not_dispatchedor
dispatched_unconfirmed. The last is the onesubmitted: falsecannot express on
its own: the request left this machine and no response came back, so the issue
may exist. On that outcomeissue_urlis 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.refusedis 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 apackages:
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 aslatestis refused, because it names whatever is published
next. Approving is offline and consults no registry.pmcp trust list-packagesprints every record (decision, time,registry:name@version,
integrity), andpmcp trust revoke-package <name>drops every version's
approval whilepmcp 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 newpackages:section takesallowlistanddenylistglobs,
and they match the package name only, nevername@version, so a package
author cannot satisfy*-mcpby publishing version1.0.0-mcp. A
version-bearing entry such asevil-pkg@1.2.3would 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.allowlistmatch lets a discovered package provision without a
recorded approval. Apackages.denylistmatch 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.yamlcan 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.approverecords the file's
current bytes and replaces any earlier decision for that path (there is no
history);listprints decision, digest, timestamp and path;revokedrops
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.jsonor
.mcp-gateway-policy.yamlis ignored untilpmcp 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, underpmcp --project, the served project),
directly or through a symlink, is refused rather than
read, andpmcp trust approvealso refuses a store inside the checkout that
contains the file being approved, so it never reports an approval that
pmcp --projectwould 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 makesis_approvedanswerFalserather 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 mode0o600.
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, nonpx, 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_serveruses it to
resolve and pin a discovered package before storing it (see Security). See
Consiliency/pmcp#230.
Removed
- The
gh issue createfallback ingateway.submit_feedbackis gone. When the
gateway had no API token it used to shell out togh issue create --repo …if the
ghCLI was onPATH. 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
namesPMCP_FEEDBACK_TOKENand thepmcp guidance --feedback-submission onverb,
and the URL still files the issue by hand. The path was removed rather than gated
becauseghauthenticates from whatever the inherited environment andgh's own
stored credentials provide, so pmcp could not say — and could not tell you —
which identity posted. The handler's lastasyncio.create_subprocess_execand the
file's lastshutiluse went with it: this path now spawns nothing. See
Consiliency/pmcp#230. GITHUB_TOKENandGH_TOKENare no longer read anywhere on the feedback
path. Previously an ambientGITHUB_TOKENwas 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. OnlyPMCP_FEEDBACK_TOKENis 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_TOKENstill appears ingateway.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
headerswas filled from the project store, so a cloned repository that shipped
.env.pmcpas a link to another file you own, plus a remote server pointing at a
host it chose, could put that file's values in anAuthorizationheader to that
host — through the gateway's config load, a remote connection,pmcp statusand
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 ofpmcp 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.envstill follows its link (a dotfiles repository), and
every repository-controlled file — the project.env.pmcp, a tenantpmcp.env, and
the.envand.env.pmcpat the served project root, and the.envthat
startup discovers by walking up from pmcp's installed files (which, for pmcp
installed in a checkout's.venvbyuv runorpip 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 auv toolorpip --user
install the walk reaches~/.env, in the home directory, which is the operator's
own and loads as before; a.envthe 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 loadfor the startup.envand 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.envand
.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: withHOME
unset, a.env.pmcpthat setHOMEmoved the user store onto a link the checkout
shipped;Http_Proxy(httpx takes any case) sent every outbound request, the
Authorizationheader included, through a proxy the repository named;
SSLKEYLOGFILEwrote TLS session keys to a file it chose; andLD_PRELOAD,
NODE_OPTIONS,UV_INDEX_URLorPYTHONPATHreached the children pmcp starts
without a filter —systemctlforpmcp doctorandpmcp status, the service
restart,pmcp upgrade, the CLI probes and the npm resolver — andsystemctl
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 readsos.environby name instead of
credential_value, if any process is started without anenv=from the builder,
ifatomic_writeorenv_storeasks a yes/no existence check, or ifsrc/pmcp
reads an environment variable that is not classified as path-and-trust,
provenance-gated or operational, or if anything butcredential_valuereads 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, soLEAK=${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'sPMCP_AUTH_TOKEN,HTTP_PROXYor
SSLKEYLOGFILEnever 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
multidict6.7.0 → 6.9.1 to clear advisoryGHSA-54p9-h82j-f925.
multidictis transitive (aiohttp→multidict, andaiohttp→yarl→
multidict), so this is lockfile-only, like theanyiobump: the repo floors
direct dependencies inpyproject.tomland locks transitive ones. The D-01
pip-audit --strictgate is green again. -
Unknown-
kidtokens can no longer drive an outbound JWKS fetch per request.
A token whosekidis 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 unknownkidis checked against the
cached keys and gets a401. The first unknownkidin 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-kidwindow (review finding S-08). See
Consiliency/pmcp#231. -
The protected-resource metadata no longer echoes the request
Host.
/.well-known/oauth-protected-resourcebuilt itsresourcefrom the request,
so a forgedHostwas advertised back as the resource, even with the Host
allowlist configured (that route never checked it).resourceis now the
operator-configuredresource_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 itskidchooses the key, so an
unauthenticated caller could pair an allowed algorithm with a key of another
type (for exampleES256against a published RSA key). pyjwt then raised
TypeError,ValueErrororInvalidKeyErrorwhile preparing the key, and
nothing caught it. Those now give the usual401invalid_token. Token
errors whose pyjwt text can quote the token back (such as an unknowncrit
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 advisoriespip-auditreports 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 pmcpresolves 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 aresanitize_auth_diagnostic,
PolicyManager.redact_secrets, andprocess_outputon a string and on a
structured result. A newslowpytest 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
httpx2andhttpcore22.9.1 → 2.12.0 to clear PYSEC-2026-3844
(httpcore2) and PYSEC-2026-3845 to PYSEC-2026-3849 (httpx2). Lockfile-only; the
declaredhttpx2>=2.5.0,<3.0.0range already admitted the fix. The lock gains
httpx2-jsfetch, a transitive of httpx2 2.12.0 that installs only on
sys_platform == 'emscripten'. -
Bumped
anyio4.12.0 → 4.14.2 to clear advisoriesGHSA-82r6-8w77-94w6andGHSA-5p39-cfhj-2xmp. Lockfile-only; the D-01pip-audit --strictgate is green again. -
SECURITY.mdnow 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
bypytest 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 withpmcp --project Xran 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 rootX— so a project-scoped credential in
X/.env.pmcpwas 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
plantingPMCP_MANIFEST_PATH,PMCP_CONFIGorPMCP_POLICYin 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. Butpmcploads.envand
.env.pmcpon 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
fromPath.cwd(), sopmcp --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; thepmcp trustverbs and a barepmcp(the gateway) keep
the cwd-derived guard, andpmcp trust approveadditionally 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_feedbackwill 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 refusesPMCP_FEEDBACK_TOKENunless 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_TOKENis credential-shaped, so an agent could otherwise have
stored one through that tool and had pmcp post with it. So is a token a.envin
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.envand 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_REPOstill overrides the default, but an
override that a checkout's.envintroduced is refused, as is one that is not
owner/reposhaped, 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_serverstored whatever
packagethe agent named, unpinned, and policy checked only the server
name. Allowlistinginternal-approved-tooltherefore let an agent register
that name withpackage="totally-arbitrary-evil-package"and have
gateway.provisionrunnpx -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 resolvedname@versioninto
both the install command and the server'sargs, so every later start
runs the version that was resolved rather than re-resolvinglatest. 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_serverandgateway.restart_serverrefuse a discovered
server until its exactname@versionis approved with
pmcp trust approve-package <name>@<version>or matched by a
packages.allowlistentry in the operator's policy. Apackages.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.provisionchecks 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
provisionbecausenpx -yfetches and runs the package when it spawns, so
without the gateregisterfollowed byconnect_serverwould run the package
withoutprovisionever being called.gateway.disconnect_serveris not
gated, so an already running server can still be stopped.
gateway.update_servernow 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_serveragain 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,.taror.tar.gz(any case) is refused,
matching npm-package-arg'sisFileTyperule, and so is a version whose major,
minor or patch exceeds 2^53 − 1 (JavaScript'sNumber.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 whichevernpxis onPATH; registration, the
provision gate, package approvals and the CLI all share this check.
A refusal reportsauth_state="policy_denied"only when a
packages.denylistentry matched. Every other refusal from this gate (not
approved, unresolvable identity, or argv not pinned to the approved version)
reportsauth_state="unknown", because no policy denied it.
Manifest-backed and.mcp.jsonservers need no approval, and provision,
connect, restart and update exactly as before, with one exception: a
packages.denylistentry now refuses a manifest-backed server whose npm
package it names (the entry'spackagefield, or a package itsnpx
command or install command runs: the package argument, or any package chosen
with-p,--packageor--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_serverandupdate_server, with the
samepolicy_deniedrefusal. While anypackages.denylistis 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--registryor
-c), a selector with no value, or a selected package that is not a plain
npm spec (such asgithub:owner/repoor./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.jsonservers 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_serverrefuses, before it
contacts the registry, anyenv_varsentry that is not credential-shaped (it
must end in_TOKEN,_KEY,_SECRET,_SECRETS,_PASSWORD,
_CREDENTIAL,_CREDENTIALS,_PAT,_DSNor_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 withNPM_CONFIG_,
NODE_,COREPACK_,YARN_,PNPM_orBUN_in any letter case, even when
it is credential-shaped (NPM_CONFIG__AUTH,NODE_AUTH_TOKEN).
gateway.auth_connectapplies the same rule to a discovered server, both to
the name it declared and to an explicitenv_varoverride. Before this, an
agent could register a package the operator had already approved under a
new server name withenv_vars=["npm_config_registry"], store an attacker's
registry URL throughauth_connect, and have the pinnednpx -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 asPOSTGRES_URLworks as before. A
server that needs any other kind of variable should be configured in
.mcp.jsonrather 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.yamlused to be discovered ahead of the
user's own policy andPolicyManagerstopped at the first file it found, so a
project policy replaced~/.claude/gateway-policy.yamloutright — 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
projectallowlistcannot 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
extendsDEFAULT_REDACTION_PATTERNSrather 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.jsonand.mcp-gateway-policy.yaml.
Until you runpmcp 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'scommand(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.jsonis not applied by any of its five
readers — servers, config sources,disableAutoStart,autoStartand
allowPrivateRegistry— all of which now pass through one choke point rather
than gating themselves. The project overlay search stops at$HOME, as the
.mcp.jsonsearch 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--policypath 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_CONFIGand
$PMCP_POLICYenvironment forms are unconditional only when the operator
exported them; a value a checkout's.env/.env.pmcpplanted 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 nopmcp trust revoketo 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
.envno 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_availableloads whole env files to answer a
boolean — whilesanitized_subprocess_envbuilds every downstream server's
environment fromos.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 theirload_dotenvcall
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=Falseprecedence 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'sPATHfrom every spawned server). A server's own
declaredenv_varmay still come from a plain.env:own_envis 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-auditreported 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 injwt.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
algheader is never trusted"). Also closed: PYSEC-2026-175 (PyJWKClient
passing its URI straight tourllib.request.urlopen) and PYSEC-2026-177 (an
unknown-kidtoken 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) andaiohttp(the
JWKS fetch client); plusstarlette,python-multipart,python-dotenv,
clickandpytest.aiohttp's declared floor is raised to>=3.14.2and
PyJWT's to>=2.13.0(raised again to>=2.15.0later in this release; see
the pyjwt entry above), because a floor that admits a vulnerable range is not a
pin. A new blockingauditCI job runspip-audit --strictagainst 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.lockafter 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 doctorreports 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 orPMCP_MANIFEST_PATHoverlay) 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_searchfail
for every query (keywords: null, which now means no keywords; a bad
cli_alternativesentry) or for every
query that matched the entry (a non-stringtransport), or stopped gateway startup
andgateway.refreshfor every server (an int inargs). 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.jsonentry 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.jsonservers from loading when one of them inherits its defaults, or makes
pmcp secretsdrop other servers' auth metadata. An overlay that replaces a shipped
server no longer changes howgateway.request_capabilitypicks 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 incli_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 itsdisconnect_allis cancelled before it starts.
A forceddisconnect_servercancelled while waiting for atasks/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 (arefreshthat was
mid-flight returns without reconnecting), and a cancelled forced
disconnect_serveralso stops a connect of that server sitting in its
retry backoff. Adopting a provisioned server process now waits for a
disconnect,disconnect_allorrefreshin 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 afterdisconnect_allreturned; a
provisioning handoff cancelled while it waits kills the process it was
handing over. Every bounded wait insrc/pmcpnow usespmcp.waits.bounded_wait
instead ofasyncio.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。1and full-widthlocalhostreached
127.0.0.1, and127%2E0%2E0%2E1decodes 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 inxn--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.jsonwas stored askeyset.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 underfe80::,fd00::orff02::was accepted); the local-use
NAT64 prefix64:ff9b:1::/48is 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 ashttp://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 memberPUBLIC_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 nowEmpty token.,Unsupported token algorithm.is nowThe token's algorithm is not supported., and the
shared-secret startup errorshared-secret auth mode requires auth_token.
is nowauth_token is required when auth_mode is shared-secret.. Every
fixed messagepmcp.authand 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_apprefuses a protected-resource
metadata URL that normalisation used to drop silently, omitting the
metadata route -- any URL that is not an absolutehttps://URL to a
public IP address or a DNS name other thanlocalhost, 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 plainhttp://to any host, loopback included, is refused too; see
Consiliency/pmcp#341).
The metadata route also refuses to
start rather than publish an emptyresource, which no shipped
configuration reaches. The README now documents deployments behind a
prefix-stripping proxy; the metadata404there is a known follow-up,
Consiliency/pmcp#334.
See Consiliency/pmcp#326. -
A JWKS with no usable keys is a
503, not a500. An issuer serving an
empty key set, or one whose keys are all unusable, madePyJWKSetraise
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-request500. A
failure other than the ones the fetch already mapped (for example a deeply
nested JSON body raisingRecursionError) escaped as a500and skipped the
shared failure backoff, so each queued request fetched again. Any failure now
opens the backoff and returns the key-set-unavailable503. 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_diagnosticdoes its keyword and URL-punctuation work in linear time. The keyword rule now runs throughpmcp.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
methodandid) is now answered rather than dropped:pinggets 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 bymethodfirst also stops a downstream request whose id collides with one of ours from being misrouted as our response._send_requestno longer leaks apending_requestsentry when the caller is cancelled or the write itself raises — the entry is popped in afinallyand a mid-write error still propagates. Cancellation is now propagated downstream asnotifications/cancelledongateway.cancel, on idle/ceiling timeout, and on caller cancellation (never forinitialize, 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
ValidationErrorin place of the message; pmcp re-raised it, which ended the read loop, set the server to ERROR, failed every in-flight request withConnectionError, 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 ownmcp.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,trueused to raiseAttributeErrorand drop the connection); anidthat is not a string, integer or null (a list or dict id raisedTypeErrorin the pending lookup, and atrueor1.0id resolved our request1by numeric equality); amethodthat 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 aJSONDecodeError(an integer over Python's digit limit raisesValueError, deep nesting raisesRecursionError) 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
Authorizationheader no longer turns any request into a 500.hmac.compare_digestraisesTypeErroronstrcontaining 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 ajson.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
PolicyManageris built, not when the module is imported.~/.claude/gateway-policy.{yaml,json}were joined toPath.home()at import, so aHOMEchanged 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 monkeypatchedUSER_POLICY_PATHS/DEFAULT_POLICY_PATHSis still honoured verbatim, so the ungated-vs-gated decision is unchanged. See Consiliency/pmcp#262. -
A hung
gateway.update_serverprobe is reported as a timeout again on Python 3.10._run_update_probe_commandbounds the probe withasyncio.wait_for, whoseasyncio.TimeoutErroris not the builtinTimeoutErrorand 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 asFailed to run update probe:with an empty reason. The helper now normalises to the builtin before the exception reaches a caller (the contractClientManager._send_requestalready provides), and the caller accepts either class. See Consiliency/pmcp#269. -
The credential store is written atomically (temp file →
fsync→os.replace), so an interruptedwrite_env_fileno longer truncates the file and loses its other entries. See Consiliency/pmcp#248. -
A symlinked
pmcp.envis 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.envsymlinked from a dotfiles repository for a regular file, sopmcp secrets setandgateway.auth_connectstopped 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 configpmcp setup --writeemits — 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, withoutO_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 oftrust.jsonandpackage_approvals.jsonis created at 0700 as before; both stores are located the way the helper writes them, never by a lexicalPath.resolve()(a link throughmissing/..names nothing, as the system says; see #374). A link loop and a target ending in/are refused. A project.env.pmcpis 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--projectpath 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--projectis 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.pmcpon the way to a rewrite goes through that walk too:pmcp secrets set,pmcp secrets sync(its source and its target) andgateway.auth_connect, and the load of the served project's.env.pmcpthat everypmcpcommand runs at startup, before the subcommand; a symlinked or non-regular store is refused before anything is read (at startup: skipped with onepmcp: 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": falsebysecrets setandsyncinstead of raising. Every other reader of a project.env.pmcp— a running gateway's spawn-time credential checks and env stripping, remote-header auth, andpmcp 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.envfound outside your home directory is read the same way (#367). Apackage_approvals.jsonwhose link chain lands inside the checkout being judged is refused, astrust.jsonalready 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/pmcpinstead of the pre-renameViperJuice/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 ownProject-URL: Repositorymetadata 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 claimsrepository_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 TaskGroupand
nothing else. Every remote-transport path inClientManagerruns inside an
anyio task group, andstr(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 builtinBaseExceptionGroupwhile
3.10 raisesexceptiongroup.ExceptionGroupfrom the backport. The rendered
text goes throughsanitize_auth_diagnostic, so flattening cannot widen
secret exposure — most of these sites logged the raw exception before and are
redacted now.last_error(surfaced bypmcp status,pmcp doctorand
health output) and the error stringsconnect_serveranddisconnect_server
return to their callers were rendering the group string too, and are fixed as
well. An AST guard overpmcp.client.managerfails CI if a caught exception is
interpolated into an f-string or passed tostr()anywhere inside its handler — not merely inside
aloggercall, which was the guard's first, too-narrow form. The one
exc_info=call site inClientManageris gone too:exc_infohands 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 —minLengthon identifiers,submit_feedback.title8–160 chars, bounds ontasks_result.options— so those rejections now come back as anisErrortool result readingInput validation error: …instead of an{"error": true}payload.gateway.invokenow advertisestask,trace_contextand_meta;gateway.tasks_*advertiserequestor_context;tasks_result.optionsgainstimeout_ms. Optional arguments are advertised asanyOf: [X, {"type": "null"}]and the transport gate now accepts an explicitnullfor 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 asnull.nullis never anenumvalue or part of atypearray, because host schema converters do not carry those over: OpenCode's Google provider turns anenumvaluenullinto the string"null"and drops thenullfrom a type array. That provider sends theanyOfform to Gemini as{"type": X, "nullable": true}; Claude Code and Codex pass it through unchanged. A client that reads a single flattypeper argument now findsanyOfon these arguments (Consiliency/pmcp#369). The gate does not apply pydantic's lax coercion: values such as1for a boolean or"5"for an integer on the newly advertisedinvoke.taskfields (enabled,ttl,poll_interval), which were previously accepted and coerced, are now rejected withInput validation error: 1 is not of type 'boolean'.invoke.task.ttlandinvoke.task.poll_intervalare now range-checked at the gate; the ranges are given in the Consiliency/pmcp#298 entry below.invoke.evidence_label_digestnow 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 asfailure; they are now recorded asaudit.rejectionevents withterminal_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, exceptgateway.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/callinput-schema rejections are now recorded in the scoped-advisor audit, without argument values (Consiliency/pmcp#296). A call the transport gate rejects used to returnInput validation error: …before the audit was reached, so an operator saw no attempt at all. It is now written as a newaudit.rejectionevent (not anaudit.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 withgateway_tool: nulland agateway_tool_digest),terminal_status: "invalid_arguments",rejected_argument_path, the failing location as a JSON array (a key the schema declares, an array index, ornullfor a key the caller chose, since that key can itself be a secret), andrejected_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 staysscoped_advisor_audit.v1; readers that dispatch oneventare 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 recordeddeniedwhatever its arguments, instead of getting anInput validation errorthat 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. Anaudit.invocationrecord now reads nothing the schema gate did not vouch for: a call refused by policy, or made to an unregistered name, is recordeddeniedwith 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 agateway_tool_digestof 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_idongateway.describe) is no longer recorded;gateway.invokedeclares every field the record reads, so its records are unchanged.gateway.invoke'stask.ttlandtask.poll_intervalare bounded, and
NaN/Infinity are refused at the gate (see Consiliency/pmcp#298).-
task.ttlmust 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 withInput validation error: …, and so is any value above
the maximum. -
task.poll_intervalmust be a finite number greater than 0 and at most
9,007,199,254,740. Zero, negative values,NaN,Infinityand-Infinity
are now rejected; they were previously accepted and forwarded. -
The transport gate now treats
NaNand±Infinityas 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/updatedAtorstatusthat pmcp cannot use is now
reported asnull, and the field is named in the task's new
unusable_fieldsarray, 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
ttlorpollInterval(a numeric or ISO 8601 string is
still accepted forcreatedAtandlastUpdatedAt/updatedAt); - a non-string or blank status;
- a blank timestamp;
- a
nullthe downstream sent.
Before, such a value was coerced (
"5"became 5,truebecame 1),
stored as given, or it failed the call after the downstream had already
created the task. Anullttlthat is not listed inunusable_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 anull
updatedAtno longer hides a goodlastUpdatedAt. The task'srawholds
what the downstream sent, except that non-finite numbers appear there as
null. A task whoselastUpdatedAtwas unusable now reports
updated_at: nullinstead 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,Infinityand1e400used to arrive asnull. This has two
side effects, both matching what stdio servers already got:- A
nextCursorofNaNnow 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
NaNin a downstream value that pmcp relays as sent now reaches the
caller as aNaNtoken instead ofnull. That is a tool result
(gateway.invoke) and a task result (gateway.tasks_result), not only
the first. A task'srawstill showsnull, and resource and prompt
reads pass on only their text. Fixing that for every transport is
tracked as Consiliency/pmcp#335.
- A
-
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-futurelastUpdatedAtcan 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
argumentsor task metadata contain one now fails withoutbound 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-JSONNaNliteral, and HTTP/SSE servers
silently receivednull. -
pmcp documented
ttlandpoll_intervalin seconds, but MCP defines both
in milliseconds, and pmcp forwarded them unchanged. The next entry
(Consiliency/pmcp#330) fixes that.
-
- Task
ttlandpoll_intervalare now converted between pmcp's seconds and
MCP's milliseconds (see Consiliency/pmcp#330). pmcp has always documented
gateway.invoke'stask.ttlandtask.poll_intervalin seconds. MCP
2025-11-25 definesttlandpollIntervalin milliseconds, and pmcp passed
the number through unchanged. Sotask: {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 topoll_interval. - If you run a tenant server built to pmcp's earlier tenant contract,
which describedttlin seconds, it now receives milliseconds: a caller's
ttl: 300arrives asttl: 300000. A server that reads that as seconds
keeps the task 1000× longer than asked. It must also returnttland
pollInterval(orpoll_interval) in milliseconds, or pmcp reports them
1000× too small: a returnedttl: 300is shown as0.3seconds. See
specs/tenant-code-mode-host-contract.md. - Outbound:
task.ttlis sent asttlin milliseconds (seconds × 1000,
exact).task.poll_intervalis sent aspollInterval× 1000. MCP's
TaskMetadatahas nopollInterval, so a spec-conforming server ignores
it. - Inbound: a downstream task's
ttlandpollIntervalare read in
milliseconds, and so is the snake_casepoll_intervalalias some servers
send. When both poll aliases are present,pollIntervalwins if it is
usable after conversion to seconds; otherwise a usablepoll_interval
does. pmcp reports and records them asttlandpoll_interval
in seconds, everywhere a task is returned:gateway.invoke'stask,
gateway.tasks_list,gateway.tasks_get,gateway.tasks_resultand
gateway.tasks_cancel. A task that used to showttl: 300000now shows
ttl: 300.0.ttlis now a number that may be fractional, not an
integer:1500ms is reported as1.5. Attlofnullstill 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
withInput validation error: …. A downstream value is checked as sent,
in milliseconds, under the previous entry's rules:ttlmust be an
integer from 0 to 2^63−1, andpollIntervala finite number greater than
0. Only then is it converted. ApollIntervalso small that it divides to
0 seconds is reported as unusable. gateway.invokenow recognises a task the downstream returns at the top
level of itstools/callreply ({"taskId": …}), the same way pmcp
already recorded it. Before,invokelooked only for the wrapped
{"task": …}form, so for that shape it returnedtask: null, relayed the
reply asresultin 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 ataskId.- Unchanged: a task's
rawobject, and the results pmcp relays as sent,
keep the downstream's own milliseconds.
- If you worked around this by sending milliseconds, your values are now
- 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 asworking, 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, sogateway.invokekeeps
the record of the task it just started. A listing is recorded one task at
a time, so atasks/listreply longer than the cap keeps only its last
100 tasks. - Eviction forgets pmcp's record, not the downstream's task.
gateway.tasks_getandgateway.tasks_resultstill reach it and track
it again. Until then:gateway.tasks_cancelreports 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_contextpmcp stored with it is forgotten, so send it
again.
gateway.tasks_liststill 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 onetasks/cancela 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_serveror
gateway.refreshcancels every task that was active on the server when
it was requested, with each task's newestrequestor_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 withoutforceis refused
while any remains, andgateway.refreshcounts 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. Notasks/cancelis 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_serverandgateway.restart_serverstill include it
incancelled_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 therequestor_context
pmcp holds for it. That is the newest one: ifgateway.invokesupplied 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 orrequestor_contexton 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_resultandgateway.tasks_cancel
fail withServer <name> was reconnected; this task belongs to its previous connection, and a forced teardown skips it.gateway.invoke,
gateway.tasks_resultandgateway.tasks_listreport 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 itscreated_at,tool_idorrequestor_context. Each request
uses one connection, captured when it starts. If that connection is
replaced whiletasks/resultis in flight, itstasks/getfallback is
not sent anywhere else:gateway.tasks_resultreturns 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.refreshthat cannot disconnect a server now says so.
It returnsok: falsewith
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 tracks at most 100 tasks per downstream server, finished and
pmcp config set-startup-policy add|remove|set --source project --applynow carries your prior trust approval forward when it rewrites.mcp.json; a symlinked.mcp.jsonis refused for every source (user, project and custom, apply or preview, CLI orgateway.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 ownpmcp trust approveof 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.jsonis 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 legacyinstall_serverandverify_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,pnpxorbunx, recognised under Windows and POSIX spellings alike:
any directory, either path separator, a drive prefix, any letter case and one
.exe,.cmdor.batsuffix; any other command logs no warning), and every
gateway.update_serverprobe. Previouslystart_installlogged
<args redacted>at INFO, which hid exactly which package ran, and
install_serverlogged 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,--yesand
--quietwherever they appear,--registryby name (never its value, which
can carryuser: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 withversion: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-setupnow 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/v1already pointed at, and the composite'ssetup-node@v4to
v4.4.0, the commitv4pointed at.scripts/check_workflows.pygains a
raw-text invariant that fails CI on any remoteuses:not in that form —
in workflows and in local actions,.ymland.yamlalike, and cross-checks
that inventory against the parsed document so auses:spelled in a form the
line scan cannot see ("uses":,{uses: …}, a block scalar) fails rather than
runs unpinned — and its exact
allowlist forrelease.ymlnow 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-nodev4 → 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-artifactv7.0.0 → v8.0.1 on the
release path (Consiliency/pmcp#221; hash mismatches on download now error instead of warn),
astral-sh/setup-uvv7.6.0 → v10.0.1 (Consiliency/pmcp#220) → v10.1.0 (Consiliency/pmcp#245) → v10.2.0 (Consiliency/pmcp#313),
docker/build-push-actionv7.3.0 → v7.4.0 (Consiliency/pmcp#275) anddocker/setup-buildx-action
v4.3.0 → v4.4.1 (Consiliency/pmcp#276). Each release-path bump carries itsEXPECTED_USESupdate in
the same PR. - Test harness (contributors only).
pytest-timeoutis now in thedevextra:
a test that runs past 700 s fails with a thread dump (timeout_method = "signal"),
andfaulthandleris 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 CItestjob 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 newreal_cwdmarker),
and the session stops with one named error if that temp root's ancestors contain a
package.jsonornode_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 fixedsleep-then-assert
sites now wait for the property, theelapsed < Xassertions 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:ClientManagertakes
clock=(wall time by default, sostarted_atis still an epoch) and caps the
idle re-check atIDLE_POLL_SLICE_S. - Shipped code snippets are always valid Python.
gateway.describe's L2 snippets are cut tomax_snippet_lines(default 4); fourtry/excepttemplates lost theirexceptbody 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.invokeandgateway.provisionreport failure asok: false, not by raising. Six more snippets misread the gateway's responses and are fixed: the GitHub, Postgres, SQLite and filesystem filters now unwrapresult(the downstreamtools/callresult) before filtering,gateway.describe's example checksannotations/safety_notes(it never returnedrisk_hint), andprovision_statusis polled byjob_iduntil itsstatusleavespending/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/catchcode hint is nowtry: at the
defaultmax_hint_lengthof 8 it was being cut totry/catc. The Playwright
screenshot entries incode_patterns.yamlandcode_examples.yamlnow name the
server's real tool,browser_take_screenshot, with itsfilenameargument. Gateway
tool and argument descriptions and the CLI help are clearer; thepmcp secrets set
example no longer puts the secret on the command line, andpmcp 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-PythonSafeLoaderas before.)
Each call re-parsed the 78 KB shipped manifest with PyYAML's pure-Python loader, up to
13 times pergateway.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, thePMCP_MANIFEST_PATHredirect 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.