Skip to content

feat: Add populate-tiny to Makefile - #4563

Merged
brian-hussey merged 2 commits into
mainfrom
populate-tiny
May 5, 2026
Merged

brian-hussey merged 2 commits into
mainfrom
populate-tiny

Conversation

@gcgoncalves

Copy link
Copy Markdown
Collaborator

Add a new populate level, that adds less objects (about 50/each) for a quicker execution.

marekdano
marekdano previously approved these changes May 1, 2026

@marekdano marekdano left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

vishu-bh
vishu-bh previously approved these changes May 1, 2026

@vishu-bh vishu-bh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@marekdano marekdano assigned marekdano and brian-hussey and unassigned marekdano May 5, 2026
brian-hussey
brian-hussey previously approved these changes May 5, 2026

@brian-hussey brian-hussey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved based on others reviews

gcgoncalves and others added 2 commits May 5, 2026 13:35
Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
@brian-hussey
brian-hussey dismissed stale reviews from vishu-bh, marekdano, and themself via e4b1e45 May 5, 2026 12:38
@brian-hussey
brian-hussey merged commit f158dfd into main May 5, 2026
21 checks passed
@brian-hussey
brian-hussey deleted the populate-tiny branch May 5, 2026 12:39
brian-hussey added a commit that referenced this pull request May 5, 2026
* feat: Add populate-tiny to Makefile

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
msureshkumar88 pushed a commit that referenced this pull request May 13, 2026
* feat: Add populate-tiny to Makefile

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
brian-hussey added a commit that referenced this pull request May 19, 2026
* feat: Add populate-tiny to Makefile

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
brian-hussey added a commit that referenced this pull request May 19, 2026
…ad_only, tmpfs) (#4469)

* chore: security hardening for startup logs (#4507)

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* [chore] Release version 1.0.0 (#4551)

* Update reference from tools_rust to crates in bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update version in root Cargo.toml file and reference in .bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update secrets

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update base image versions

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Corrected version of UUI dependency

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Linting updates to requirements.txt files

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update front end dependencies

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update npm deps and black finding

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* License updates and linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update to not fail on test setup

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release PII tool invocation steps (#4521)

* docs: fix release PII tool invocation steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: align release PII verification steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: fix release docs build links

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: scope PII release check to tool hooks

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* Revert "docs: fix release docs build links"

This reverts commit 88c4b7b3d1c4e462d1e06e329ab4aff8e13eec42.

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: use admin token for plugin status check

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: run discovered PII filter tests

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: remove PII unit test step

Signed-off-by: lucarlig <luca.carlig@ibm.com>

---------

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release docs build links (#4523)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update CHANGELOG with PR 4507 details

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update reference material

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Alembic linearity fix (#4547)

* Alembic linearity fix

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: handle ix_/idx_ index name mismatch in migration downgrades

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update alembic migration tools

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(alembic): drop orphaned ix_token_revocations_token_expiry on downgrade

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Final Update to CHANGELOG.md

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Address final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update cargo package details for cargo vet check

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct test coverage within previously cleaned code audit_trail_service

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Luca <luca.carlig@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Fix/alembic migration safety (#4479)

* fix(alembic): safe downgrade + roundtrip CI validation

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(alembic): add migration_metadata table for hermetic downgrade config snapshots

Introduces a new migration (f9a8b7c6d5e4) that creates a
migration_metadata(revision, key, value, created_at) table. Data
migrations that read settings.* at upgrade time now write a config
snapshot into this table so that downgrade() can use the original
values regardless of the current environment.

ba202ac1665f (migrate_user_roles_to_configurable_defaults) is wired
to this table:
- upgrade(): snapshots default_admin_role, default_user_role,
  default_team_owner_role, default_team_member_role immediately after
  reading them from settings.
- downgrade(): reads the snapshot first; falls back to live settings
  only if the table/rows are absent, logging a warning.

scripts/ci/run_upgrade_validation.sh: replace post-downgrade strict
equality assert with assert_version_present. The migration graph has a
parallel branch (aa1b2c3d4e5f) that diverges below the base-image
head; Alembic correctly leaves that branch tip in alembic_version after
downgrade. assert_version_present verifies the expected version is
present and logs the extra heads rather than failing.

All 6 CI checks pass (SQLite fresh, SQLite upgrade, PostgreSQL fresh,
PostgreSQL upgrade, SQLite roundtrip, PostgreSQL roundtrip).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore(make): add migration-test-rollback target

Adds a dedicated `make migration-test-rollback` Makefile target that
runs pytest -k 'reverse or rollback' on both SQLite and PostgreSQL test
files, followed by the full upgrade/downgrade roundtrip harness
(scripts/ci/run_upgrade_validation.sh).

Also updates the upgrade-validate help comment to reflect that it now
validates fresh, upgrade, AND roundtrip (downgrade) paths, since
run_sqlite_roundtrip/run_postgres_roundtrip were added in the previous
commit.

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* test(migration): add cross-DB schema consistency test

Adds tests/migration/test_cross_db_schema_consistency.py, a new pytest
module that boots the target image twice — once with SQLite, once with
PostgreSQL — applies Alembic migrations to head in each case, then
introspects both schemas via SQLAlchemy's Inspector API (exec'd inside
the running container) and asserts:

  test_same_tables_exist      - identical table set (66 tables)
  test_column_names_match     - same column names per table
  test_column_types_match     - equivalent types after canonicalisation
  test_primary_keys_match     - same primary key columns
  test_foreign_keys_match     - same FK relationships

Engine-specific type differences (VARCHAR vs TEXT, TIMESTAMPTZ vs
DATETIME, JSONB vs JSON, BYTEA vs BLOB, etc.) are normalised through a
_TYPE_CANON mapping so the test asserts semantic equivalence, not
string identity.  Unknown type mappings produce a clear failure message
directing the author to extend _TYPE_CANON.

Also adds make migration-test-cross-db, a dedicated Makefile target.

Verified locally: 5/5 tests pass in 22 s against
mcpgateway/mcpgateway:upgrade-ci-test (67 tables on each engine).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* docs: document migration CI coverage decision and config snapshot pattern

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(make): fix tab indentation in migration-test-rollback and migration-test-cross-db targets

Makefile recipe lines were indented with 8 spaces instead of tabs,
causing 'missing separator' parse errors that triggered the Rust CI
workflow (which monitors Makefile changes).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff errors in new migration files and cross-DB test

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff F841 and detect-secrets false positives

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix: address PR review feedback - migration safety hardening

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): remove extra blank lines in db.py (E303)

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update secrets baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* chore(deps): bump cpex-rate-limiter to 0.0.6 (#4596)

* chore(deps): bump cpex-rate-limiter to 0.0.6 (TLS support release)

Pulls in the TLS-support release published from cpex-plugins#74,
which enables the rate-limiter plugin to connect to managed Redis
endpoints that require TLS (`rediss://` URLs).  Operators with
managed Redis services that mandate in-transit encryption (AWS
ElastiCache with encryption enabled, Redis Cloud, IBM Cloud
Databases for Redis, etc.) can now point the plugin at those
endpoints without tripping the prior `InvalidClientConfig: TLS
feature not enabled` plugin-init failure.

Three coordinated edits:

- ``pyproject.toml:15`` — bump uv ``exclude-newer`` date pin from
  ``2026-04-22T23:59:59Z`` (covered up to and including 0.0.4) to
  ``2026-05-31T23:59:59Z`` so uv sees the 0.0.6 wheel
  (uploaded 2026-05-05T08:13:13).
- ``pyproject.toml:281`` — bump min-version constraint
  ``cpex-rate-limiter>=0.0.4`` -> ``cpex-rate-limiter>=0.0.6`` so
  fresh installs pull the TLS-enabled wheel.
- ``uv.lock`` — regenerated via
  ``uv lock --upgrade-package cpex-rate-limiter`` to pin the
  resolved version + per-platform wheel hashes for 0.0.6.

Local-dev defaults unchanged.  ``docker-compose.yml``'s
``REDIS_URL=redis://redis:6379/0`` continues to work — TLS is
opt-in via a ``rediss://`` URL.

Verified locally: ``uv sync --extra plugins`` installs 0.0.6 cleanly,
``cpex_rate_limiter.rate_limiter.RateLimiterPlugin`` imports without
error, and the plugin-framework loader unit tests stay green.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

* chore(deps): align RateLimiterPlugin version field in plugins/config.yaml

The shipped ``plugins/config.yaml`` carried ``version: "0.0.3"`` for
``RateLimiterPlugin`` long after the published wheel had moved on.
Bumping to ``0.0.6`` to match the version the previous commit pulls
in (the TLS-support release from cpex-plugins#74).

This field is operator-facing metadata mirrored from the plugin's
own ``plugin-manifest.yaml``; it's not a dependency constraint, so
it does not affect resolution.  Updating it keeps the shipped
sample config truthful about which plugin version operators are
loading by default.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

---------

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat: Add populate-tiny to Makefile (#4563)

* feat: Add populate-tiny to Makefile

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Remove rc3 references (#4567)

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

Comment: By-passing checks as queue too long, this is low risk change.
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: document cargo-vet release workflow (#4600)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* bugfix/3825-playwright-htmx-dom-detach (#4264)

* fix: update scope['modified_path'] for server_id extraction with root_path (#4270)

Fixes #4266

The /mcp endpoint was returning ALL tools instead of server-scoped tools
when accessed via /servers/{id}/mcp in reverse proxy deployments with
root_path configured. The /sse endpoint worked correctly.

Root cause: MCPPathRewriteMiddleware computed app_path by stripping
root_path prefix (line 3026) but never updated scope['modified_path'].
When streamablehttp_transport.py:2898 reads modified_path to extract
server_id via regex (r'^/servers/(?P<server_id>[^/]+)/mcp'), the regex
fails to match paths like '/prefix/servers/123/mcp', causing server_id
to be None and tools/list to return all tools instead of server-scoped.

Solution: Update scope['modified_path'] = app_path after computing the
app-relative path. This ensures streamablehttp_transport receives the
path without root_path prefix for correct server_id extraction.

Changes:
- Set scope['modified_path'] to app_path after stripping root_path
- Add regression tests verifying modified_path is app-relative
- All existing MCPPathRewriteMiddleware tests pass

Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(transport): return 405 on session-less GET /mcp (#4205) (#4284)

* fix(docker): install wget in a2a-echo-agent image for healthcheck

The compose healthcheck for a2a_echo_agent runs `wget -qO- http://localhost:9100/health`,
but the debian:bookworm-slim runtime stage only installed ca-certificates and tzdata —
so every probe failed with "exec: wget: executable file not found in $PATH" and the
container stayed perpetually unhealthy despite the agent itself listening correctly.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): return 405 on session-less GET /mcp (#4205)

A strict MCP client opens a passive SSE stream via GET right after initialize
to receive server-initiated messages. When ContextForge cannot anchor that
stream to a session (stateful sessions disabled, session-core disabled in
Rust, or no Mcp-Session-Id presented), it previously either committed to an
empty event stream (Rust live-stream relay) or handed off to the SDK which
held the GET open without emitting events. Strict clients then timed out on
the empty stream and reported "Failed to initialize server session".

The MCP Streamable HTTP spec allows the server to return 405 for such GETs;
this change takes that route in both gateways.

Python (mcpgateway/transports/streamablehttp_transport.py):
  - SessionManagerWrapper.handle_streamable_http now returns 405 with
    Allow: POST, DELETE for GET when either settings.use_stateful_sessions
    is false or no Mcp-Session-Id was presented.
  - The check runs after _validate_server_id() and the RBAC gate so bogus
    server IDs still 404 and unauthorized callers still 403, preserving the
    "RBAC before processing" invariant.
  - Branch-specific detail messages distinguish "stateful sessions disabled"
    from "no session id presented" so clients don't retry in a loop.
  - logger.info on rejection with path + flags for ops visibility.

Rust (crates/mcp_runtime/src/lib.rs):
  - forward_transport_request returns 405 for GET when either
    session_core_enabled is false or no session id is presented in header
    or session_id query parameter. Without session_core, Rust cannot anchor
    an SSE stream; without a session id, there is nothing to anchor to.
  - The 405 response carries the same capability headers as every other
    terminal response in the function.
  - tracing::info! on rejection and a RuntimeStats.transport.get_rejected_no_session
    counter so ops can distinguish "no traffic" from "every client rejected".
  - One pre-existing test (server_scoped_transport_wrappers_inject_server_header)
    updated to provide a session id + record so it still exercises the
    server-id header injection path.

Tests:
  - Python: 3 new parametrized tests covering stateless/no-session/pass-through
    and both Mcp-Session-Id header aliases; server-scoped variant confirms
    405 fires only after server-id validation (and that 404 still wins for
    nonexistent servers).
  - Rust: 3 new tests covering 405 without session id, 405 when session_core
    disabled even with header, and live-stream branch still reached when a
    valid session id is presented.
  - Also un-hollows two pre-existing header-parsing tests
    (test_handle_streamable_http_non_tuple_header_skipped,
    test_handle_streamable_http_non_bytes_header_skipped) that would have
    been silently short-circuited by the new 405 path.

Note: This fixes the downstream GET-SSE cascade described in the comment
on #4205. The upstream per-client session-isolation problem from the
original reproducer (shared pooled upstream session leaking state between
downstream clients) is a separate architectural change and will be
addressed in a follow-up.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): address review feedback on GET /mcp 405 path (#4205)

Follow-up to 252e8c228. Addresses review feedback surfaced by the
PR-review agents:

- Rust `runtime_session_id_from_request` now also accepts
  `x-mcp-session-id`, restoring parity with the Python gateway so a
  client isn't accepted by one runtime and 405'd by the other.
- Split the Rust 405 counter: `get_rejected_no_session` stays as the
  total and a new `get_rejected_session_core_disabled` subcounter
  isolates the deployment-config branch, so ops can distinguish client
  misuse from session-core being off.
- Added a Python Prometheus counter (`transport_get_rejected_total`)
  with matching `no_session_id` / `stateful_disabled` outcomes to close
  the observability gap between runtimes.
- Split log levels on both sides: `warn` for the operator-facing
  disabled-session / stateful-disabled branch, `debug` for the
  no-session-id branch (routine strict-client probing).
- Added a regression guard test that GET with RBAC denial returns 403
  (not 405) and emits no `Allow` header.
- Session-affinity `except Exception` in the Python gateway now warns
  instead of silently rendering infrastructure failures as 405s.
- 404 on unknown server no longer allowed to leak an `Allow` header.
- GET OAuth-enabled server test asserts 401 before the 405 path.
- Rust happy-path tests snapshot counters to assert no 405 tick.
- "Why 405 (not 404/400)" rationale added to both comment blocks.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): gate Rust 405 inside live-stream branch (#4205)

Integration tests `get_and_delete_mcp_routes_forward_to_internal_transport_bridge`
and `live_stream_core_disabled_falls_back_to_python_transport_get` were
failing in CI: the standalone 405 block in `forward_transport_request`
pre-empted the Python transport-bridge fallback path, even for GETs the
runtime could have cleanly proxied.

The empty-stream cascade the 405 was defending against only arises when
the live-stream relay is entered without a session anchor. Move the 405
guard inside that branch so GETs that don't request SSE (or where
live_stream_core is disabled) fall through to the existing Python-proxy
fallback, which has its own 405 logic. Update the matching unit test to
set `Accept: text/event-stream` so it still reaches the gate.

No change to the counter semantics or Python-side behaviour. Full
workspace test suite (Rust + Python) passes.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(rust): apply clippy 1.95.0 lint fixes in non-PR crates

Rustc stable moved from 1.94.1 to 1.95.0 between the last main Rust CI
run (#3704, 2026-04-16) and PR #4284, enabling two additional lints at
workspace `-D warnings` level. The new lints flag pre-existing code in
crates outside this PR's surface area, blocking Rust clippy CI for every
branch that touches any Rust file.

Apply clippy's exact suggestions:

- `crates/wrapper/src/json_rpc_id_fast.rs`: collapse a `FieldName`
  match arm's nested `if` into a match guard (`collapsible_match`).
- `crates/a2a_runtime/src/event_store.rs`: drop a redundant
  `.into_iter()` in a `.zip()` argument (`useless_conversion`).
- `crates/a2a_runtime/src/server.rs`: collapse two match-arm nested
  `if`s (`get`|`cancel` and `list`) into match guards.

Verified with `cargo clippy --workspace --all-targets -- -D warnings`
and `cargo test --workspace` on rustc 1.95.0. No behaviour change.

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat(runtime): runtime-mutable RUST_MCP_MODE + A2A_MODE via admin API (#4296)

* feat(runtime): runtime-mutable MCP and A2A mode via admin API

Add PATCH /admin/runtime/mcp-mode and PATCH /admin/runtime/a2a-mode that
flip the public /mcp ingress and registered-A2A invocation path between
shadow and edge at runtime without a restart. Boot env vars
(RUST_MCP_MODE, RUST_A2A_MODE) still select the initial mode; overrides
are in-memory only. When Redis is configured the override propagates
cluster-wide via pub/sub on contextforge:runtime:mode with a per-runtime
monotonic INCR counter and a 24h hint key per runtime so freshly-started
pods reconcile to the cluster's current desired override on boot.

Off and full boot modes are intentionally not flippable (409): off has
no Rust sidecar to swap to; full keeps Rust as the owner of MCP session/
event-store/resume cores and flipping to shadow would orphan that state.

The reverse-proxy caveat (admin flips don't propagate past an upstream
nginx until the proxy is reconfigured) is documented and tracked
separately in #4278.

Closes #4273

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): preserve session-auth-reuse safety invariant under override

Codex stop-time review caught that `override="edge"` on a shadow-boot
deployment silently bypassed the documented safety invariant for routing
public traffic to Rust: `_should_mount_public_rust_transport` had been
loosened to require only `experimental_rust_mcp_runtime_enabled`, skipping
`experimental_rust_mcp_session_auth_reuse_enabled` (and the analogous
`delegate_enabled` for A2A). A boot=shadow deployment with an admin-issued
edge override would have started routing public MCP to a Rust sidecar
that never opted into session-auth-reuse.

Fix:
- `_should_mount_public_rust_transport` keeps the original invariant
  unchanged. Override="shadow" still forces Python; override="edge" now
  matches the default (honors the invariant). Same shape for
  `_should_delegate_a2a_to_rust`.
- Narrow `_FLIPPABLE_BOOT_MODES` to `{edge}`. Only boot=edge deployments
  have both flags true and can safely flip. boot=shadow PATCHes now
  return 409 with an operator-facing explanation of the invariant.
- Two belt-and-braces unit tests exercise the read side directly (even
  if a stale hint somehow landed override=edge in state, the transport
  function refuses to route to Rust without the safety flags).
- Two router tests cover the new 409 for shadow boot (MCP + A2A).
- Docs updated: architecture doc, ADR 043 addendum, crate README/STATUS,
  .env.example, docker-compose.yml.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): allow shadow override clearance and discard incompatible hints

Codex stop-time review caught a new dead-end introduced by the previous
safety-invariant fix: after restricting _FLIPPABLE_BOOT_MODES to {edge},
a shadow-boot deployment that inherited a stale override=edge via a
Redis hint (left by a prior edge-boot deploy) had no API path to clear
state — every PATCH returned 409. The operator's only recourse was
flushing Redis by hand.

Fix is two-sided:

Router:
- Gate on target mode, not boot mode. mode=shadow accepted from any
  boot that has a dispatcher mounted (shadow or edge). mode=edge still
  requires boot=edge (safety invariant). off and full still 409 for any
  PATCH (no dispatcher).
- Replaced _FLIPPABLE_BOOT_MODES with _DISPATCHER_BOOT_MODES and
  _EDGE_OVERRIDE_BOOT_MODES to make the two gates explicit.

Coordinator:
- _reconcile_from_hint now checks whether the hint's target mode can
  safely take effect on this deployment. Incompatible hints (e.g. a
  former edge-boot pod's hint replayed on a shadow-boot deploy) are
  discarded and boot_reconcile_status is set to the new INCOMPATIBLE_HINT
  value, surfaced via /health. Without this guard, shadow-boot pods
  would boot with override=edge in state while the transport layer
  refused to honor it — misleading diagnostics that required the
  escape-hatch PATCH to clear.
- Added _deployment_allows_mode helper that mirrors the safety-invariant
  checks in version.py (lazy-imported to avoid the version→settings cycle).

Tests:
- test_patch_mcp_mode_shadow_clears_stale_override_on_shadow_boot
  exercises the escape hatch end-to-end.
- test_reconcile_from_hint_discards_incompatible_mode verifies the
  coordinator refuses to apply a shadow-boot-incompatible hint.
- test_reconcile_from_hint_accepts_shadow_mode_on_shadow_boot covers
  the positive case (shadow is always compatible).

Docs updated to explain the per-target-mode gating and the
INCOMPATIBLE_HINT status.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): discard stranded hints on boot=off and boot=full pods

Codex stop-time review caught that the previous _deployment_allows_mode
check returned True for shadow unconditionally — but boot=off has no
Rust sidecar at all and boot=full mounts a plain RustMCPRuntimeProxy
with no dispatcher, so neither boot mode has a mechanism that could
observe an override. A shadow hint replayed onto either pod would
strand in state (effective_mode=shadow in diagnostics; transport keeps
routing to its boot-determined target), and the router 409s for any
PATCH on those boots so the operator would have no API path to clear it.

Tighten _deployment_allows_mode to match the router's admit logic:
shadow accepts only when a dispatcher is mounted (boot=shadow or edge
for MCP; boot=shadow or edge for A2A), and edge still requires the
session-auth-reuse / delegate-enabled invariant.

Three new tests:
- test_reconcile_from_hint_discards_shadow_on_full_boot
- test_reconcile_from_hint_discards_any_mode_on_off_boot
- test_reconcile_from_hint_discards_a2a_hint_on_a2a_off_boot

Architecture doc updated to spell out all three INCOMPATIBLE_HINT cases.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): gate live pub/sub on compat check + structured rejection reasons

Third-pass review identified a critical and several important issues:

C1: _listen_loop applied remote pub/sub messages without the same
compatibility check _reconcile_from_hint enforces. A remote pod's
incompatible flip would strand in local state. Mirror the same gate.

I2: _listen_loop's recovery branch fired on any non-exception return,
including get_message returning None. A pubsub broken in a way that
returns None reliably without raising would falsely re-promote
cluster_propagation from DEGRADED to REDIS. Gate the recovery on
message is not None — the existing idle-timeout branch already handles
the legit no-message case.

I1: Document the deliberate "discarded hint left in Redis" choice in
_reconcile_from_hint's comment.

S4+S5+S6 (combined refactor):
- Promote _deployment_allows_mode to return MoveCompatibility enum
  with structured rejection reasons (NO_DISPATCHER, BOOT_FULL_STRANDS,
  EDGE_NEEDS_SAFETY_FLAG, OK).
- Split BootReconcileStatus.INCOMPATIBLE_HINT into three granular
  values (INCOMPATIBLE_NO_DISPATCHER, INCOMPATIBLE_BOOT_FULL,
  INCOMPATIBLE_SAFETY_FLAG) so /health distinguishes the cases.
- Move the helper to version.py next to the safety-invariant functions
  with a new public deployment_allows_override_mode wrapper.
- Router now calls deployment_allows_override_mode directly, replacing
  the parallel _DISPATCHER_BOOT_MODES/_EDGE_OVERRIDE_BOOT_MODES sets
  with a single source of truth. The 409 detail string is generated
  from the MoveCompatibility reason and names the exact env var or
  flag the operator needs to flip.

Tests added (I3 + I4 + I5 + S1 + S2):
- test_reconcile_from_hint_discards_edge_on_full_boot
- test_reconcile_from_hint_discards_edge_on_off_boot
- test_incompatible_hint_does_not_delete_redis_key_or_downgrade_propagation
  (S1 + S2: assert hint key untouched AND cluster_propagation stays REDIS)
- test_patch_mcp_mode_edge_409_when_boot_mode_full
- test_deployment_allows_override_mode_mcp_table (8 parametrized rows)
- test_deployment_allows_override_mode_a2a_table (6 parametrized rows)

Test fixtures (edge_boot, off_boot, full_boot, shadow_boot) now patch
the underlying settings instead of just monkeypatching the boot-mode
helpers — the new deployment_allows_override_mode inspects raw
settings, so fixture drift would have masked real bugs.

Architecture doc updated to enumerate the three INCOMPATIBLE_* values
and document the live pub/sub compat check.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): drop self-contradicting 'full' recommendation from NO_DISPATCHER 409

Codex stop-time review caught that the NO_DISPATCHER 409 detail told
operators "Boot with RUST_..._MODE='shadow' or 'edge' (or 'full' for
MCP) to enable overrides." But boot=full returns BOOT_FULL_STRANDS
for the same reason (no dispatcher mounted) — switching to full would
just trade one 409 for another. Worse, A2A has no 'full' mode at all,
so the parenthetical was incoherent for A2A 409 responses.

Drop the "(or 'full' for MCP)" clause. Add an inline comment explaining
why full is intentionally excluded so a future maintainer doesn't
re-add it. Two existing tests now also assert "'shadow' or 'edge'" is
present and "'full'" is absent — pins the contract.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(secrets): refresh detect-secrets baseline after rebase onto main

Line numbers in .secrets.baseline are tied to file content. After
rebasing this branch onto main (which itself touched .secrets.baseline
in upstream commits), the baseline needed a re-scan to match the
post-rebase final file state. No new secrets detected.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): defer generic cluster-wide settings propagation framework (ADR-050)

Records the decision to keep the runtime-mutable mode override
implementation purpose-built for MCP/A2A rather than extracting a
generic ClusterStatePrimitive framework today. Captures the alternative
abstraction sketch for future reference and enumerates the trigger
conditions that would justify revisiting (second concrete consumer in
flight, third independently-tracked use case on the horizon, or a need
for cross-consumer coordination like atomic multi-setting flips).

This is N=1 — CLAUDE.md's "three similar lines is better than a
premature abstraction" applies. The right shape will emerge from
copy-modifying for the second consumer and diffing the result.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): scrub AI-assistant name from ADR-050

Repo guideline forbids naming AI assistants in committed diffs. The
rationale section in ADR-050 cited 'Codex' as the source of stop-time
review iterations; rephrase to describe the review process without
naming the tool. Also drop the explicit 'CLAUDE.md' file citation in
favor of 'repo convention' since the supporting quote stands on its
own.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): tighten ADR-050 claims, deliver defenses checklist, add 0049 row

Comment-analyzer and code-reviewer review of ADR-050 surfaced several
claims that would rot, one promised section that didn't exist, and a
missing index row that visually framed ADR-050 as having skipped a
number.

ADR-050 fixes:

- Correct the "≈900 LOC together" claim to ~1.5 kLOC. Actual line counts
  are runtime_state.py 977 + runtime_admin_router.py 489 = 1,466.
- Replace "six stop-time review iterations" with the verifiable "five
  follow-up fix(runtime): commits on top of the initial feat(runtime):"
  so the count is grep-checkable from git log.
- Drop the "repo convention is explicit: three similar lines beats a
  premature abstraction" framing — that line is from contributor tooling,
  not anything written in the repo. The N=1 argument now stands on its
  own merits.
- Replace "the most-reviewed code on the branch" superlative with the
  timeless "received unusually heavy review during initial development."
- Caption the ClusterStatePrimitive code block as a hypothetical sketch
  so readers who grep-arrive mid-document don't think the class exists.
- Move the reverse-proxy WARN attribution from runtime_state.py to
  runtime_admin_router.py (it lives in the router).
- Name LISTEN_LOOP_DEGRADE_THRESHOLD = 5 and
  RUNTIME_STATE_HINT_TTL_SECONDS = 24 * 60 * 60 in the context section
  so future drift in those constants is detectable from this ADR.
- Reconcile trigger-#1's "build both consumers side-by-side" with the
  rationale's "copy-modify" guidance: copy-modify first, then extract
  the framework from the resulting two-implementation diff.
- Deliver the "non-obvious defenses" checklist that the Negative
  consequence promised but never wrote. The new "Defenses checklist for
  the copy-modify path" section enumerates 12 defenses, each tied to a
  bug class caught during this PR's review iterations.

Index fix:

- Add the missing 0049 row for "Multi-Protocol Virtual Servers"
  (Accepted, Architecture, 2026-04-16). The 048→050 jump made ADR-050
  look like it had skipped a number when ADR-049 was just un-indexed.
- Other duplicate ADR numbers on disk (005, 034, 038, 048) and other
  index gaps are pre-existing and tracked separately in #4290.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(transports): swappable MCPIngressMount + nginx-style Rust public proxy (ADR-051)

Replace MCPStreamableHTTPModeDispatcher (closed-set, two transports,
policy baked into dispatch) with MCPIngressMount: a thin ASGI
indirection that holds a registry of named ingresses + a swappable
selector. Adding a new ingress is one register() call; selection
policy is one function.

Three ingresses registered today:

- "python" — the existing MCPRuntimeHeaderTransportWrapper. Always
  registered.
- "rust-internal" — the existing RustMCPRuntimeProxy that forwards
  via httpx to MCP_RUST_LISTEN_HTTP / UDS (trusted-internal listener).
  Registered for boot=shadow/edge.
- "rust-public" — new RustMCPPublicProxyApp that does nginx-style
  reverse-proxy to MCP_RUST_PUBLIC_LISTEN_HTTP. Adds X-Forwarded-*,
  RFC 7239 Forwarded, X-Real-IP. Streams both directions, preserves
  Authorization/Cookie. Registered only for boot=edge.

Selector chooses between rust-internal and rust-public based on the
new settings.mcp_rust_ingress (default "internal" preserves prior
behavior bit-for-bit). Selector preserves all existing safety
invariants — _should_mount_public_rust_transport() is consulted first,
shadow override / boot=full / boot=off behave identically to before,
the runtime-mode override admin API requires no changes.

Test results: 1839 passing across the touched modules. Lint clean.

ADR-051 documents the design decision, alternatives considered, and
migration notes from MCPStreamableHTTPModeDispatcher. Index updated.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transports): close XFF spoof + harden Rust public proxy ingress

Response to review of 19677706b (MCPIngressMount + nginx-style Rust
public proxy). Closes a security issue and several diagnostic gaps in
the public proxy and ingress selector.

Security fix — RustMCPPublicProxyApp:

- _build_forwarded_headers now drops client-supplied Forwarded /
  X-Forwarded-* / X-Real-IP from the inbound iteration before
  re-deriving them from request.client. The prior code appended to
  the client-supplied X-Forwarded-For, letting any caller spoof their
  own source IP to Rust's /_internal/mcp/authenticate callback in the
  no-nginx-in-front deployment this proxy targets. New
  _FORWARDED_CHAIN_HEADERS frozenset enumerates the strip set.
- _format_forwarded_for emits RFC 7239 §6.3-conformant `for=unknown`
  (no synthetic `:0`) for absent clients, brackets IPv6 addresses
  exactly once, and tolerates already-bracketed input.

Selector hardening — _select_mcp_ingress / config:

- mcp_rust_ingress is now Literal["internal", "public"] so Pydantic
  rejects typos at boot.
- Selector only returns "rust-public" when boot_mcp_runtime_mode() ==
  "edge" — the public listener isn't bound on shadow boot, so a stale
  edge override + mcp_rust_ingress=public combination would otherwise
  return an unregistered name and silently fall back to Python at
  debug-only log severity.
- _build_mcp_transport_app emits a logger.error (not warning) when
  mcp_rust_ingress=public is set on shadow boot, so the misconfig
  survives the project-default LOG_LEVEL=ERROR.

Error handling — RustMCPPublicProxyApp:

- __call__ now distinguishes asyncio.CancelledError (re-raised),
  httpx.HTTPError (logged with type name + 502), and any other
  exception (logger.exception + 500), so a non-HTTPError no longer
  bypasses the handler and produces a silent Starlette 500.
- _body_iter logs CancelledError at debug (common for SSE client
  disconnect) and other exceptions at error (rare upstream death),
  before re-raising. The finally still calls aclose to return the
  upstream connection to the pool.
- Auth-relevant or server-error upstream statuses (401/403/5xx) now
  log a warning with method+path+status so a credential-rotation flood
  is visible without raising the global log level.

MCPIngressMount diagnostics:

- dispatch's selector-miss paths (fallback used, 503-on-no-fallback)
  now log warning with the unknown name AND self.names(), instead of
  debug-only on the fallback path with no log on the 503 path. A
  misrouted ingress should never be silent.

Tests:

- New tests/unit/mcpgateway/transports/test_rust_mcp_public_proxy.py
  (24 tests). Covers _format_forwarded_for parametrized over IPv4
  with/without port, IPv6, already-bracketed IPv6, None/empty host;
  _build_forwarded_headers spoof rejection + hop-by-hop strip + auth
  preservation + unknown-client emission; proxy ASGI behavior across
  status passthrough, 502 on httpx.HTTPError, 500 on unexpected,
  parametrized warning on (401, 403, 500, 503), non-HTTP scope → 404,
  CancelledError re-raise on send and mid-stream debug-branch, lazy
  client lifecycle, multi-chunk SSE streaming, and aclose-on-exit.
- Two new tests in test_mcp_ingress_mount.py pin the warning-level
  log severity on selector-miss + 503 paths.
- Two new tests in test_main_extended.py pin the boot-time error log
  for the shadow + mcp_rust_ingress=public misconfig and the selector
  downgrade to rust-internal under the same condition.

Doc rot fixes:

- mcp_ingress_mount.py module docstring uses the full ADR filename
  (was an ellipsis).
- version.py docstring references MCPIngressMount instead of the
  removed MCPStreamableHTTPModeDispatcher.
- ADR-051 migration notes use test-group descriptors instead of
  pinned line numbers / test names.

Test results: 652 passing across the touched modules. Lint clean
(black, ruff, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(quality): fix pylint W0706/W2301/R0401 and close coverage gaps

PR review feedback:

Pylint findings:

- W0706 (try-except-raise) at runtime_state.py:933 and
  rust_mcp_public_proxy.py:237: removed the redundant
  ``except asyncio.CancelledError: raise`` blocks. CancelledError is
  BaseException in Python 3.8+, so the surrounding ``except Exception``
  / ``except httpx.HTTPError`` arms never caught it; the explicit
  re-raise was no-op and made pylint complain. Behavior is unchanged
  (asyncio still cancels both coroutines naturally), and the intent is
  preserved as a comment.
- W2301 (unnecessary-ellipsis) at mcp_ingress_mount.py:37: dropped the
  ``...`` from the ASGIApp Protocol's ``__call__`` body — the
  docstring already counts as a body for Protocol methods.
- R0401 (cyclic-import) on the runtime_state ↔ version pair: added
  per-line ``# pylint: disable=cyclic-import`` to the two lazy imports
  in runtime_state.py. Pylint follows function-body imports for cycle
  detection; the lazy imports break the runtime cycle but pylint still
  flags the static graph. The full extract (move MoveCompatibility +
  coercers to a shared module) is bigger refactor than this PR
  warrants.

Coverage gaps closed (per-file delta):

- routers/runtime_admin_router.py 97.3% → 100%. Added three tests for
  ``_warn_if_behind_reverse_proxy``: header detection emits warning
  with header names, no warning when no forwarded headers, defensive
  early return when ``request.headers`` is None.
- transports/mcp_ingress_mount.py 94.7% → 100%. Added test for
  ``set_fallback`` covering swap mid-life and ``None`` restoring
  503-on-miss behavior.
- transports/rust_mcp_public_proxy.py 96.5% → 100%. Added test for
  ``_body_iter`` taking the ``except Exception`` arm on a non-cancel
  mid-stream error: asserts ``logger.exception`` fires with traceback.
- runtime_state.py 89.9% → 99%. Added 9 tests covering: defensive
  ValueError branches in 4 accessor methods (parametrized), idempotent
  ``coordinator.start()``, ``_reconcile_from_hint`` no-redis and
  malformed-mode paths, ``_cleanup_pubsub`` timeout / exception /
  fallback-to-close paths, listen-loop bytes-payload decode + idle
  recovery + malformed-payload + empty-data + uncoerceable-fields
  branches.
- version.py: targeted user-noted gaps (379, 434, 509, 570, 623) all
  closed. Added tests for last_change population in
  _mcp_runtime_status_payload / _a2a_runtime_status_payload, A2A
  shadow override forcing _should_delegate_a2a_to_rust False, and
  the boot_mcp_transport_mount public wrapper. Line 509
  (NO_DISPATCHER fallthrough — _coerce_runtime rejects unknown kinds
  upstream) marked ``# pragma: no cover`` as a defensive default.

Other defensive defaults marked ``# pragma: no cover``:

- runtime_admin_router.py:99 (OK fallthrough — caller already returned
  for OK upstream).
- runtime_state.py:164, 171 in ``_move_compat_to_reconcile_status``
  (OK case shouldn't reach here per docstring; final return is a
  defensive default for future MoveCompatibility variants).

Test results: 1272 passing across the touched modules. Lint clean
(black, ruff, pylint 10.00/10, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* build(container): consolidate on Containerfile.lite; deprecate others (#4297)

Closes #2613.

The top-level `Containerfile` duplicated the .lite build with weaker layer
caching and shipped build tooling in the runtime image. `Containerfile.scratch`
had no frontend-builder stage (admin UI bundle absent), failed under QEMU
(amd64-only), and had no consumer in CI, docs, or the Makefile. Both are
removed; `Containerfile.lite` is now the only canonical gateway image.

Apply the same layer-consolidation and caching pass across `Containerfile.lite`
and the per-component Containerfiles:

- Merge adjacent RUN steps (microdnf + ln + useradd; profile.d setup + chmod;
  Rust MCP + A2A cargo builds; ...) to reduce layer churn.
- Reorder COPYs from stable to volatile so frontend/Rust-binary changes no
  longer invalidate the heavy venv-install layer.
- Use COPY --chown=1001:0 in the UBI nodejs frontend-builder stage — fixes
  EACCES on package-lock.json under the non-root UBI default user — and
  COPY --chmod=0755 on run scripts to drop follow-up chmod layers.
- Replace \`npm install --frozen-lockfile\` (a yarn/pnpm flag npm silently
  ignores) with \`npm ci\`.
- Silence pre-existing hadolint DL3013/DL3041 findings with targeted ignore
  comments consistent with the file-header policy.

Update every touchpoint of the deprecated images: Makefile podman/docker[-dev]
targets, fly.toml, docker-compose commented migration examples, the
docker-scan workflow matrix and trigger paths, .travis.yml,
.pre-commit-config.yaml, .bumpversion.cfg, MANIFEST.in, and user-facing docs
(packaging, release-management, compose, openshift, container, fly-io, argocd
tutorial, MULTIPLATFORM, roadmap). Update the docker-scan unit test to match
the trimmed workflow. Incidentally fix the stale \$(CONTAINERFILE) typo in
the snyk-iac Makefile target.

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat: add HCS-14 UAID support for A2A agents (#4125)

* add HCS-14 UAID support for A2A agents

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* migration

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* lint error fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test case fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* pyporject.toml

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test coverage

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat: add base58 dependency and UAID routing config
  - Add base58>=2.1.1 dependency for UAID generation support
  - Add uaid_allowed_domains config setting for cross-gateway routing security
  - Document UAID_ALLOWED_DOMAINS in .env.example
  Related to #3956

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* a2a_agent_id column size change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat: add UAID support to Admin UI
  - Add UAID generation checkbox to create/edit A2A agent forms
  - Add UAID registry and protocol configuration fields
  - Display UAID vs UUID badge in agents table
  - Show UAID metadata section in agent view modal
  - Add JavaScript toggle logic for UAID fields
  - Update form population for editing UAID agents
  - Rebuild UI bundle with UAID changes
  Completes HCS-14 UAID feature with full UI support.
  Related to #3956

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid ui change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* revert id column length

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid in admin ui create form

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* edit form UAID

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* length change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* Add security documentation for UAID-based cross-gateway routing feature

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* documentation

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test case

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test: Add coverage for UAID cross-gateway routing edge cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* alembic head

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test coverage

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* base58 and its deps resolved

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security: fix SSRF and domain allowlist bypass in UAID cross-gateway routing

Fixes two critical security vulnerabilities in UAID-based cross-gateway routing:

1. Domain Allowlist Bypass (CVE-level)
   - Previous: endpoint.endswith(d) allowed "evilexample.com" to bypass "example.com" allowlist
   - Fixed: Require exact match OR proper subdomain prefix (.example.com)
   - Now correctly blocks: "evilexample.com", "notexample.com"
   - Still allows: "gateway.example.com", "example.com", "sub.gateway.example.com"

2. SSRF Injection via nativeId (CVE-level)
   - Previous: nativeId field directly interpolated into URL without validation
   - Attack vectors blocked:
     * Protocol injection: file:///etc/passwd, gopher://internal
     * User-info bypass: evil@127.0.0.1 (routes to internal IP)
     * Path injection: gateway.com/admin (access internal endpoints)
   - Legitimate ports still allowed: gateway.example.com:8443

Security validations added before URL construction:
- Reject protocol prefixes (://)
- Reject @ character (user-info bypass)
- Reject path components (/)
- Validate hostname format via urlparse
- Extract domain from endpoint:port for allowlist checking

Test coverage (8 new tests):
- test_invoke_remote_agent_domain_allowlist_bypass_attack
- test_invoke_remote_agent_domain_allowlist_subdomain_valid
- test_invoke_remote_agent_domain_allowlist_exact_match
- test_invoke_remote_agent_ssrf_protocol_injection
- test_invoke_remote_agent_ssrf_userinfo_bypass
- test_invoke_remote_agent_ssrf_path_injection
- test_invoke_remote_agent_ssrf_internal_ip (documents behavior)
- test_invoke_remote_agent_port_allowed

Related: #3956

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* style: apply black and isort formatting to schemas and UAID tests

- black: Fix line length in PluginPolicyItem config field description
- isort: Move First-Party imports to correct section in test files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid split

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* ruff

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security test cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* varibale correct

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* ruff

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* rebase fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat(a2a): A2A-1.0 UAID path in Rust sidecar + federation-loop guard

Splits UAID cross-gateway routing between the two runtimes by intent:
the Python gateway keeps the ContextForge-to-ContextForge federation
shape (target `/a2a/{uaid}/invoke` on a peer CF gateway), and the Rust
a2a_runtime gets a first-class A2A-1.0-compliant path that POSTs the
original JSON-RPC 2.0 body directly to the agent URL encoded in the
UAID's `nativeId`. The two paths are intentionally not reverse-compatible.

Key pieces:

- `crates/a2a_runtime/src/uaid.rs` (new): HCS-14 parser with SSRF
  rejection (user-info, path injection, unsupported scheme),
  case-insensitive domain allowlist (exact or `.suffix`), DoS length
  cap, sealed `RoutingTarget` with private fields and a `Proto` enum
  (`FromStr` + exhaustiveness test), stable `UaidError::code()` for
  structured logs.
- `crates/a2a_runtime/src/server.rs`: UAID dispatch in
  `handle_a2a_invoke` with local-resolve-first and cross-gateway
  fall-through; SSE proxy (`handle_uaid_cross_gateway_streaming`)
  with Last-Event-ID replay and a bounded JSON fallback that enforces
  `max_response_body_bytes` via a streaming `read_bounded_body` helper
  (per-chunk + cumulative cap); startup validation and rate-limited
  warning for an empty allowlist; explicit one-time security warning
  for the unforwarded-auth tradeoff.
- `mcpgateway/main.py` + `mcpgateway/services/a2a_service.py`: kind-
  dispatch resolve (name/id/uaid) with 403 for visibility-denial so
  the sidecar can distinguish hidden-from-absent; URL-encoded UAID in
  the CF-federation target path; dropped redundant `agent_id` body
  field; structured error logging without leaking 5xx internals.

Federation-loop prevention covers both A->B->A and self-referential
`endpoint_url` loops:

- `X-Contextforge-UAID-Hop: N` counter replaces the earlier binary
  marker. Every outbound (cross-gateway, local-agent, and the
  backward-compat `POST /invoke`) stamps `N+1`; every inbound rejects
  at `uaid_max_federation_hops` (default 5).
- Strict parser with byte-identical rules in both runtimes: pure
  ASCII digits only (no Unicode digits), no whitespace on single
  values, saturating at `2**31 - 1`; RFC 7230 section 3.2.2 coalesced
  form `"0, 10"` parses each element with OWS trim and takes the max
  so smuggled low values can't mask a real high value.
- Max-of-variants defense against header-case smuggling: `HashMap`
  iteration order can't be used to select a low value paired with a
  high one, and axum's multi-valued `HeaderMap` is drained via
  `get_all` + max.
- Named-agent paths translate resolve's 403 to 404 at the axum edge
  so private-agent existence never leaks through the status code or
  the error text (`map_agent_resolve_err` helper).

Config additions:

- Python `uaid_allowed_domains` (list), `uaid_max_length` (2048),
  `uaid_max_federation_hops` (default 5).
- Rust `A2A_RUST_UAID_ALLOWED_DOMAINS`, `A2A_RUST_UAID_MAX_LENGTH`,
  `A2A_RUST_UAID_MAX_FEDERATION_HOPS` (same defaults).

Tests:

- Python unit/integration: kind-dispatch resolve (uaid/id/name fall-
  back), parse_hop_count parametric cases including Unicode digits,
  whitespace, overflow, coalesced RFC 7230 form and smuggling, local-
  agent hop stamping on `_execute_agent_request`, 3-hop chain locking
  in the stamp-N+1 vs reject-at-max contract, TestClient-level
  `/a2a/{agent_name}/invoke` header plumbing.
- Rust integration: 12+ UAID dispatch tests (local hit, cross-gateway
  forward, streaming SSE proxy, body-size cap on chunked responses,
  per-chunk cap, hop-limit at max for UAID and plain names,
  multi-variant smuggling, coalesced header, 403->404 on named paths,
  case-insensitive `/invoke` hop guard).
- Rust uaid.rs: 16 unit tests covering parse/validate/SSRF/allowlist
  round-trip plus `Proto` exhaustiveness and `UaidError::code()`
  stability contracts.

Closes #3956

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Jonathan Springer <jps@s390x.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(#4205): isolate upstream MCP sessions per downstream session (#4299)

* feat(services): scaffold UpstreamSessionRegistry for 1:1 upstream binding (#4205)

Introduces UpstreamSessionRegistry, which maps
(downstream_session_id, gateway_id) to a single upstream MCP ClientSession.
Replaces the identity-keyed MCPSessionPool's sharing semantics: two
downstream MCP sessions — even carrying the same user identity — can no
longer receive the same pooled upstream session. This is the core
isolation change #4205's reproducer needs.

Design:
  - 1:1 per (downstream_session_id, gateway_id). Never shared.
  - Connection reuse preserved WITHIN one downstream session: a client
    making many tool calls against gateway G through session S still
    hits the upstream's initialize exactly once.
  - Health probe on reuse after idle_validation_seconds (60s default),
    chain is ping → list_tools → list_prompts → list_resources → skip.
    Failed probe recreates the upstream session.
  - Transport + ClientSession live inside a dedicated asyncio.Task whose
    anyio cancel scope is bound to that task, not to the request handler
    (#3737). Shutdown is signal-driven via an asyncio.Event; cancellation
    only as a last resort with a timeout.
  - No configurable settings. The tuning surface of the old pool
    (max_per_key, TTL, etc.) collapses under the 1:1 constraint.
  - Purely in-process. Multi-worker stickiness for a downstream session
    is the session-affinity layer's concern (to be extracted next) —
    each worker's registry only sees requests affinity has already
    routed to it.

API surface:
  - acquire() async context manager keyed by downstream_session_id,
    gateway_id, url, headers, transport_type. Rejects empty ids.
  - evict_session(downstream_session_id) for DELETE /mcp paths.
  - evict_gateway(gateway_id) for gateway rotation/removal.
  - close_all() for app shutdown.
  - snapshot() -> RegistrySnapshot for /admin and logs.
  - Module-level init/get/shutdown singleton accessors matching the
    shape of get_mcp_session_pool() so call-sites migrate cleanly.

Tests (16, all passing):
  isolation across downstream sessions (the #4205 invariant), reuse
  within a session, distinct upstreams per gateway for one session,
  concurrent acquires collapse to one create via the per-key lock,
  idle probe + reuse, failed probe + recreate, evict_session /
  evict_gateway / close_all teardown, dead owner-task detection,
  gateway-internal header stripping, and the singleton accessors.
  A FakeClientSession + fake SessionFactory keep the tests hermetic.

Not yet wired: nothing in the codebase calls the registry yet. The
follow-up commits will extract session-affinity to its own module,
wire startup/shutdown + DELETE eviction, migrate tool/prompt/resource
services, refactor gateway health checks, delete MCPSessionPool, and
remove the associated feature flags.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(services): wire UpstreamSessionRegistry into app lifecycle (#4205)

Adds startup init, shutdown drain, and DELETE-triggered eviction for
the registry introduced in the previous commit. All additive; the old
MCPSessionPool still runs alongside for now.

main.py:
  - init_upstream_session_registry() at startup, unconditionally (no
    feature flag — the registry is always on).
  - shutdown_upstream_session_registry() in the teardown block,
    ordered between pool shutdown and SharedHttpClient shutdown to
    ensure upstream sessions close before their HTTP transports go.

cache/session_registry.py (downstream session registry, distinct from
the upstream one this PR introduces):
  - remove_session() now calls get_upstream_session_registry().evict_session(id)
    as its last step. Fires on every path that drops a downstream session:
    explicit DELETE /mcp, internal /_internal/mcp/session DELETE, SSE
    disconnect housekeeping, database-backed session expiry. Wrapped so
    a missing singleton (tests, early shutdown) or an eviction exception
    never masks downstream teardown.

Tests (5 new, all passing):
  remove_session → evict_session forwarding; remove_session tolerating
  an uninitialized singleton; remove_session surviving an evict_session
  that raises; shutdown close_all() call + singleton clear; re-init
  after shutdown returns a fresh instance. Existing session_registry
  coverage tests still green (72 tests, no regressions).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(tool_service): route MCP tool calls through UpstreamSessionRegistry (#4205)

Both MCP call-sites in tool_service.invoke_tool (SSE at ~L5048, StreamableHTTP
at ~L5230) now acquire their upstream ClientSession from the registry when a
downstream Mcp-Session-Id is in scope. This is the first visible behavioural
change of the #4205 fix: two downstream MCP sessions served by the same user
now build SEPARATE upstream sessions, so stateful upstream servers (counter,
etc.) no longer leak state between downstream clients.

Changes:
  - Replace the conditional pool path with an unconditional registry path,
    gated on the presence of a downstream Mcp-Session-Id (read from the
    transport's request_headers_var via a new _downstream_session_id_from_request
    helper) AND a gateway_id AND not tracing_active.
  - Drop the `settings.mcp_session_pool_enabled` check at the call-sites.
    The registry is always on; its applicability is determined by whether a
    downstream session id is in scope.
  - Keep the per-call fallback path for callers without a downstream
    session id: admin UI test-invoke, internal /rpc, and anything that
    drives the tool_service outside the streamable-http transport.
  - Preserve the tracing trade-off: when tracing_active, skip the registry
    to allow per-request traceparent/X-Correlation-ID injection.
  - Remove the now-unused `get_mcp_session_pool, TransportType` import from
    mcp_session_pool; TransportType is now imported from the registry module.
  - _downstream_session_id_from_request uses a lazy import of
    streamablehttp_transport.request_headers_var to avoid a circular
    dependency at module load time.

Tests:
  - NEW test_invoke_tool_mcp_two_downstream_sessions_hit_registry_with_distinct_ids:
    the direct-consequence #4205 test — two invocations with different
    downstream session ids must produce two acquire() calls with distinct
    (session_id, gateway_id) keys, same gateway, different sessions.
  - Rewrote test_invoke_tool_mcp_pooled_path_does_not_inject_trace_headers
    as the equivalent registry-path test (same invariant: reused upstream
    transports must not receive per-request trace headers).
  - Rewrote 4 pool-hit tests in test_tool_service_coverage.py to use the
    registry API (and set request_headers_var with a session id).
  - 870 related tests pass; no regressions.

This migration leaves the old pool in place — it simply isn't called from
tool_service anymore. Prompt/resource/gateway call-sites still point at the
pool and will migrate in the next commits.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(prompts, resources): route MCP calls through UpstreamSessionRegistry (#4205)

Mirrors the tool_service migration: prompt_service and resource_service acquire
their upstream MCP ClientSession from the registry when a downstream Mcp-Session-Id
is in scope, falling back to per-call sessions otherwise. Same 1:1 isolation,
same connection reuse within a session, same trace-header trade-off.

Changes:
  - prompt_service._fetch_prompt_from_gateway: replace pool path with registry
    path; drop the `settings.mcp_session_pool_enabled` gate; drop the now-unused
    `pool_user_identity` local.
  - resource_service.invoke_resource SSE + StreamableHTTP helpers: same
    rewrite. Also delete the `pool_user_identity` normalization block at
    line ~1708 (no longer referenced).
  - upstream_session_registry: add `downstream_session_id_from_request_context()`
    so the three services share one implementation. tool_service now aliases
    the shared helper rather than carrying its own copy.
  - TransportType is imported from upstream_session_registry in all three
    services; the pool's copy becomes unused and disappears in the
    hollow-and-rename step.

Tests:
  - Rewrote 4 pool-hit tests in test_resource_service.py as registry-path
    tests (set request_headers_var with a downstream session id, patch
    get_upstream_session_registry). Renamed for accuracy:
    test_sse_session_pool_used_and_signature_validated →
      test_sse_registry_used_and_signature_validated
    test_invoke_resource_streamablehttp_uses_session_pool_when_available →
      test_invoke_resource_streamablehttp_uses_registry_when_available
    The two "pool not initialized falls back" tests now simulate an
    uninitialized registry via RuntimeError from get_upstream_session_registry.
  - 1173 service-layer tests pass; no regressions.

Two holdouts still touch the pool: gateway_service's health check (task #19)
and the pool itself (task #23 deletes its upstream-session code and renames
the file to session_affinity.py).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* refactor(gateway_service): drop pool from health checks (#4205)

Gateway health checks are system operations — no downstream MCP session id
is in scope — so they can't key the UpstreamSessionRegistry. They also
don't benefit meaningfully from connection reuse: each probe is a one-shot
initialize round-trip to detect reachability. Use a fresh per-call session
unconditionally.

Changes:
  - _check_single_gateway_health (streamablehttp branch): replaces the
    pool-or-fallback block with a single streamablehttp_client +
    ClientSession per probe.
  - Drop the `mcp_session_pool_explicit_health_rpc` feature flag usage
    (the setting itself disappears in the config cleanup commit). The
    initialize() round-trip is the probe; no optional list_tools() needed.
  - Imports: drop `get_mcp_session_pool, TransportType` from the pool
    import; keep `register_gateway_capabilities_for_notifications`
    (still used by three other call-sites in gateway_service). Also drop
    the now-unused `anyio` import (was used only for the pool branch's
    fail_after on list_tools).

Tests:
  - Rewrote test_streamablehttp_pool_not_initialized_falls_back_to_per_call_session
    as test_streamablehttp_health_uses_per_call_session (since per-call is
    now unconditional, that's the whole behavior the test pins).
  - Deleted test_streamablehttp_pool_used_and_explicit_health_rpc_calls_list_tools
    (exercised a code path that no longer exists).
  - Deleted tests/unit/mcpgateway/services/test_gateway_explicit_health_rpc.py
    entirely — it only tested the MCP_SESSION_POOL_EXPLICIT_HEALTH_RPC
    feature flag's on/off branches.
  - 168 gateway_service tests pass; no regressions.
Signed-off-by: Jonathan Springer <jps@s390x.com>

* refact…
prakhar-singh1928 added a commit that referenced this pull request May 19, 2026
…ad_only, tmpfs) (#4469)

* chore: security hardening for startup logs (#4507)

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* [chore] Release version 1.0.0 (#4551)

* Update reference from tools_rust to crates in bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update version in root Cargo.toml file and reference in .bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update secrets

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update base image versions

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Corrected version of UUI dependency

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Linting updates to requirements.txt files

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update front end dependencies

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update npm deps and black finding

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* License updates and linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update to not fail on test setup

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release PII tool invocation steps (#4521)

* docs: fix release PII tool invocation steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: align release PII verification steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: fix release docs build links

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: scope PII release check to tool hooks

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* Revert "docs: fix release docs build links"

This reverts commit 88c4b7b3d1c4e462d1e06e329ab4aff8e13eec42.

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: use admin token for plugin status check

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: run discovered PII filter tests

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: remove PII unit test step

Signed-off-by: lucarlig <luca.carlig@ibm.com>

---------

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release docs build links (#4523)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update CHANGELOG with PR 4507 details

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update reference material

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Alembic linearity fix (#4547)

* Alembic linearity fix

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: handle ix_/idx_ index name mismatch in migration downgrades

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update alembic migration tools

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(alembic): drop orphaned ix_token_revocations_token_expiry on downgrade

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Final Update to CHANGELOG.md

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Address final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update cargo package details for cargo vet check

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct test coverage within previously cleaned code audit_trail_service

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Luca <luca.carlig@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Fix/alembic migration safety (#4479)

* fix(alembic): safe downgrade + roundtrip CI validation

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(alembic): add migration_metadata table for hermetic downgrade config snapshots

Introduces a new migration (f9a8b7c6d5e4) that creates a
migration_metadata(revision, key, value, created_at) table. Data
migrations that read settings.* at upgrade time now write a config
snapshot into this table so that downgrade() can use the original
values regardless of the current environment.

ba202ac1665f (migrate_user_roles_to_configurable_defaults) is wired
to this table:
- upgrade(): snapshots default_admin_role, default_user_role,
  default_team_owner_role, default_team_member_role immediately after
  reading them from settings.
- downgrade(): reads the snapshot first; falls back to live settings
  only if the table/rows are absent, logging a warning.

scripts/ci/run_upgrade_validation.sh: replace post-downgrade strict
equality assert with assert_version_present. The migration graph has a
parallel branch (aa1b2c3d4e5f) that diverges below the base-image
head; Alembic correctly leaves that branch tip in alembic_version after
downgrade. assert_version_present verifies the expected version is
present and logs the extra heads rather than failing.

All 6 CI checks pass (SQLite fresh, SQLite upgrade, PostgreSQL fresh,
PostgreSQL upgrade, SQLite roundtrip, PostgreSQL roundtrip).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore(make): add migration-test-rollback target

Adds a dedicated `make migration-test-rollback` Makefile target that
runs pytest -k 'reverse or rollback' on both SQLite and PostgreSQL test
files, followed by the full upgrade/downgrade roundtrip harness
(scripts/ci/run_upgrade_validation.sh).

Also updates the upgrade-validate help comment to reflect that it now
validates fresh, upgrade, AND roundtrip (downgrade) paths, since
run_sqlite_roundtrip/run_postgres_roundtrip were added in the previous
commit.

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* test(migration): add cross-DB schema consistency test

Adds tests/migration/test_cross_db_schema_consistency.py, a new pytest
module that boots the target image twice — once with SQLite, once with
PostgreSQL — applies Alembic migrations to head in each case, then
introspects both schemas via SQLAlchemy's Inspector API (exec'd inside
the running container) and asserts:

  test_same_tables_exist      - identical table set (66 tables)
  test_column_names_match     - same column names per table
  test_column_types_match     - equivalent types after canonicalisation
  test_primary_keys_match     - same primary key columns
  test_foreign_keys_match     - same FK relationships

Engine-specific type differences (VARCHAR vs TEXT, TIMESTAMPTZ vs
DATETIME, JSONB vs JSON, BYTEA vs BLOB, etc.) are normalised through a
_TYPE_CANON mapping so the test asserts semantic equivalence, not
string identity.  Unknown type mappings produce a clear failure message
directing the author to extend _TYPE_CANON.

Also adds make migration-test-cross-db, a dedicated Makefile target.

Verified locally: 5/5 tests pass in 22 s against
mcpgateway/mcpgateway:upgrade-ci-test (67 tables on each engine).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* docs: document migration CI coverage decision and config snapshot pattern

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(make): fix tab indentation in migration-test-rollback and migration-test-cross-db targets

Makefile recipe lines were indented with 8 spaces instead of tabs,
causing 'missing separator' parse errors that triggered the Rust CI
workflow (which monitors Makefile changes).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff errors in new migration files and cross-DB test

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff F841 and detect-secrets false positives

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix: address PR review feedback - migration safety hardening

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): remove extra blank lines in db.py (E303)

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update secrets baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* chore(deps): bump cpex-rate-limiter to 0.0.6 (#4596)

* chore(deps): bump cpex-rate-limiter to 0.0.6 (TLS support release)

Pulls in the TLS-support release published from cpex-plugins#74,
which enables the rate-limiter plugin to connect to managed Redis
endpoints that require TLS (`rediss://` URLs).  Operators with
managed Redis services that mandate in-transit encryption (AWS
ElastiCache with encryption enabled, Redis Cloud, IBM Cloud
Databases for Redis, etc.) can now point the plugin at those
endpoints without tripping the prior `InvalidClientConfig: TLS
feature not enabled` plugin-init failure.

Three coordinated edits:

- ``pyproject.toml:15`` — bump uv ``exclude-newer`` date pin from
  ``2026-04-22T23:59:59Z`` (covered up to and including 0.0.4) to
  ``2026-05-31T23:59:59Z`` so uv sees the 0.0.6 wheel
  (uploaded 2026-05-05T08:13:13).
- ``pyproject.toml:281`` — bump min-version constraint
  ``cpex-rate-limiter>=0.0.4`` -> ``cpex-rate-limiter>=0.0.6`` so
  fresh installs pull the TLS-enabled wheel.
- ``uv.lock`` — regenerated via
  ``uv lock --upgrade-package cpex-rate-limiter`` to pin the
  resolved version + per-platform wheel hashes for 0.0.6.

Local-dev defaults unchanged.  ``docker-compose.yml``'s
``REDIS_URL=redis://redis:6379/0`` continues to work — TLS is
opt-in via a ``rediss://`` URL.

Verified locally: ``uv sync --extra plugins`` installs 0.0.6 cleanly,
``cpex_rate_limiter.rate_limiter.RateLimiterPlugin`` imports without
error, and the plugin-framework loader unit tests stay green.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

* chore(deps): align RateLimiterPlugin version field in plugins/config.yaml

The shipped ``plugins/config.yaml`` carried ``version: "0.0.3"`` for
``RateLimiterPlugin`` long after the published wheel had moved on.
Bumping to ``0.0.6`` to match the version the previous commit pulls
in (the TLS-support release from cpex-plugins#74).

This field is operator-facing metadata mirrored from the plugin's
own ``plugin-manifest.yaml``; it's not a dependency constraint, so
it does not affect resolution.  Updating it keeps the shipped
sample config truthful about which plugin version operators are
loading by default.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

---------

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat: Add populate-tiny to Makefile (#4563)

* feat: Add populate-tiny to Makefile

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Remove rc3 references (#4567)

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

Comment: By-passing checks as queue too long, this is low risk change.
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: document cargo-vet release workflow (#4600)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* bugfix/3825-playwright-htmx-dom-detach (#4264)

* fix: update scope['modified_path'] for server_id extraction with root_path (#4270)

Fixes #4266

The /mcp endpoint was returning ALL tools instead of server-scoped tools
when accessed via /servers/{id}/mcp in reverse proxy deployments with
root_path configured. The /sse endpoint worked correctly.

Root cause: MCPPathRewriteMiddleware computed app_path by stripping
root_path prefix (line 3026) but never updated scope['modified_path'].
When streamablehttp_transport.py:2898 reads modified_path to extract
server_id via regex (r'^/servers/(?P<server_id>[^/]+)/mcp'), the regex
fails to match paths like '/prefix/servers/123/mcp', causing server_id
to be None and tools/list to return all tools instead of server-scoped.

Solution: Update scope['modified_path'] = app_path after computing the
app-relative path. This ensures streamablehttp_transport receives the
path without root_path prefix for correct server_id extraction.

Changes:
- Set scope['modified_path'] to app_path after stripping root_path
- Add regression tests verifying modified_path is app-relative
- All existing MCPPathRewriteMiddleware tests pass

Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(transport): return 405 on session-less GET /mcp (#4205) (#4284)

* fix(docker): install wget in a2a-echo-agent image for healthcheck

The compose healthcheck for a2a_echo_agent runs `wget -qO- http://localhost:9100/health`,
but the debian:bookworm-slim runtime stage only installed ca-certificates and tzdata —
so every probe failed with "exec: wget: executable file not found in $PATH" and the
container stayed perpetually unhealthy despite the agent itself listening correctly.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): return 405 on session-less GET /mcp (#4205)

A strict MCP client opens a passive SSE stream via GET right after initialize
to receive server-initiated messages. When ContextForge cannot anchor that
stream to a session (stateful sessions disabled, session-core disabled in
Rust, or no Mcp-Session-Id presented), it previously either committed to an
empty event stream (Rust live-stream relay) or handed off to the SDK which
held the GET open without emitting events. Strict clients then timed out on
the empty stream and reported "Failed to initialize server session".

The MCP Streamable HTTP spec allows the server to return 405 for such GETs;
this change takes that route in both gateways.

Python (mcpgateway/transports/streamablehttp_transport.py):
  - SessionManagerWrapper.handle_streamable_http now returns 405 with
    Allow: POST, DELETE for GET when either settings.use_stateful_sessions
    is false or no Mcp-Session-Id was presented.
  - The check runs after _validate_server_id() and the RBAC gate so bogus
    server IDs still 404 and unauthorized callers still 403, preserving the
    "RBAC before processing" invariant.
  - Branch-specific detail messages distinguish "stateful sessions disabled"
    from "no session id presented" so clients don't retry in a loop.
  - logger.info on rejection with path + flags for ops visibility.

Rust (crates/mcp_runtime/src/lib.rs):
  - forward_transport_request returns 405 for GET when either
    session_core_enabled is false or no session id is presented in header
    or session_id query parameter. Without session_core, Rust cannot anchor
    an SSE stream; without a session id, there is nothing to anchor to.
  - The 405 response carries the same capability headers as every other
    terminal response in the function.
  - tracing::info! on rejection and a RuntimeStats.transport.get_rejected_no_session
    counter so ops can distinguish "no traffic" from "every client rejected".
  - One pre-existing test (server_scoped_transport_wrappers_inject_server_header)
    updated to provide a session id + record so it still exercises the
    server-id header injection path.

Tests:
  - Python: 3 new parametrized tests covering stateless/no-session/pass-through
    and both Mcp-Session-Id header aliases; server-scoped variant confirms
    405 fires only after server-id validation (and that 404 still wins for
    nonexistent servers).
  - Rust: 3 new tests covering 405 without session id, 405 when session_core
    disabled even with header, and live-stream branch still reached when a
    valid session id is presented.
  - Also un-hollows two pre-existing header-parsing tests
    (test_handle_streamable_http_non_tuple_header_skipped,
    test_handle_streamable_http_non_bytes_header_skipped) that would have
    been silently short-circuited by the new 405 path.

Note: This fixes the downstream GET-SSE cascade described in the comment
on #4205. The upstream per-client session-isolation problem from the
original reproducer (shared pooled upstream session leaking state between
downstream clients) is a separate architectural change and will be
addressed in a follow-up.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): address review feedback on GET /mcp 405 path (#4205)

Follow-up to 252e8c228. Addresses review feedback surfaced by the
PR-review agents:

- Rust `runtime_session_id_from_request` now also accepts
  `x-mcp-session-id`, restoring parity with the Python gateway so a
  client isn't accepted by one runtime and 405'd by the other.
- Split the Rust 405 counter: `get_rejected_no_session` stays as the
  total and a new `get_rejected_session_core_disabled` subcounter
  isolates the deployment-config branch, so ops can distinguish client
  misuse from session-core being off.
- Added a Python Prometheus counter (`transport_get_rejected_total`)
  with matching `no_session_id` / `stateful_disabled` outcomes to close
  the observability gap between runtimes.
- Split log levels on both sides: `warn` for the operator-facing
  disabled-session / stateful-disabled branch, `debug` for the
  no-session-id branch (routine strict-client probing).
- Added a regression guard test that GET with RBAC denial returns 403
  (not 405) and emits no `Allow` header.
- Session-affinity `except Exception` in the Python gateway now warns
  instead of silently rendering infrastructure failures as 405s.
- 404 on unknown server no longer allowed to leak an `Allow` header.
- GET OAuth-enabled server test asserts 401 before the 405 path.
- Rust happy-path tests snapshot counters to assert no 405 tick.
- "Why 405 (not 404/400)" rationale added to both comment blocks.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): gate Rust 405 inside live-stream branch (#4205)

Integration tests `get_and_delete_mcp_routes_forward_to_internal_transport_bridge`
and `live_stream_core_disabled_falls_back_to_python_transport_get` were
failing in CI: the standalone 405 block in `forward_transport_request`
pre-empted the Python transport-bridge fallback path, even for GETs the
runtime could have cleanly proxied.

The empty-stream cascade the 405 was defending against only arises when
the live-stream relay is entered without a session anchor. Move the 405
guard inside that branch so GETs that don't request SSE (or where
live_stream_core is disabled) fall through to the existing Python-proxy
fallback, which has its own 405 logic. Update the matching unit test to
set `Accept: text/event-stream` so it still reaches the gate.

No change to the counter semantics or Python-side behaviour. Full
workspace test suite (Rust + Python) passes.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(rust): apply clippy 1.95.0 lint fixes in non-PR crates

Rustc stable moved from 1.94.1 to 1.95.0 between the last main Rust CI
run (#3704, 2026-04-16) and PR #4284, enabling two additional lints at
workspace `-D warnings` level. The new lints flag pre-existing code in
crates outside this PR's surface area, blocking Rust clippy CI for every
branch that touches any Rust file.

Apply clippy's exact suggestions:

- `crates/wrapper/src/json_rpc_id_fast.rs`: collapse a `FieldName`
  match arm's nested `if` into a match guard (`collapsible_match`).
- `crates/a2a_runtime/src/event_store.rs`: drop a redundant
  `.into_iter()` in a `.zip()` argument (`useless_conversion`).
- `crates/a2a_runtime/src/server.rs`: collapse two match-arm nested
  `if`s (`get`|`cancel` and `list`) into match guards.

Verified with `cargo clippy --workspace --all-targets -- -D warnings`
and `cargo test --workspace` on rustc 1.95.0. No behaviour change.

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat(runtime): runtime-mutable RUST_MCP_MODE + A2A_MODE via admin API (#4296)

* feat(runtime): runtime-mutable MCP and A2A mode via admin API

Add PATCH /admin/runtime/mcp-mode and PATCH /admin/runtime/a2a-mode that
flip the public /mcp ingress and registered-A2A invocation path between
shadow and edge at runtime without a restart. Boot env vars
(RUST_MCP_MODE, RUST_A2A_MODE) still select the initial mode; overrides
are in-memory only. When Redis is configured the override propagates
cluster-wide via pub/sub on contextforge:runtime:mode with a per-runtime
monotonic INCR counter and a 24h hint key per runtime so freshly-started
pods reconcile to the cluster's current desired override on boot.

Off and full boot modes are intentionally not flippable (409): off has
no Rust sidecar to swap to; full keeps Rust as the owner of MCP session/
event-store/resume cores and flipping to shadow would orphan that state.

The reverse-proxy caveat (admin flips don't propagate past an upstream
nginx until the proxy is reconfigured) is documented and tracked
separately in #4278.

Closes #4273

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): preserve session-auth-reuse safety invariant under override

Codex stop-time review caught that `override="edge"` on a shadow-boot
deployment silently bypassed the documented safety invariant for routing
public traffic to Rust: `_should_mount_public_rust_transport` had been
loosened to require only `experimental_rust_mcp_runtime_enabled`, skipping
`experimental_rust_mcp_session_auth_reuse_enabled` (and the analogous
`delegate_enabled` for A2A). A boot=shadow deployment with an admin-issued
edge override would have started routing public MCP to a Rust sidecar
that never opted into session-auth-reuse.

Fix:
- `_should_mount_public_rust_transport` keeps the original invariant
  unchanged. Override="shadow" still forces Python; override="edge" now
  matches the default (honors the invariant). Same shape for
  `_should_delegate_a2a_to_rust`.
- Narrow `_FLIPPABLE_BOOT_MODES` to `{edge}`. Only boot=edge deployments
  have both flags true and can safely flip. boot=shadow PATCHes now
  return 409 with an operator-facing explanation of the invariant.
- Two belt-and-braces unit tests exercise the read side directly (even
  if a stale hint somehow landed override=edge in state, the transport
  function refuses to route to Rust without the safety flags).
- Two router tests cover the new 409 for shadow boot (MCP + A2A).
- Docs updated: architecture doc, ADR 043 addendum, crate README/STATUS,
  .env.example, docker-compose.yml.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): allow shadow override clearance and discard incompatible hints

Codex stop-time review caught a new dead-end introduced by the previous
safety-invariant fix: after restricting _FLIPPABLE_BOOT_MODES to {edge},
a shadow-boot deployment that inherited a stale override=edge via a
Redis hint (left by a prior edge-boot deploy) had no API path to clear
state — every PATCH returned 409. The operator's only recourse was
flushing Redis by hand.

Fix is two-sided:

Router:
- Gate on target mode, not boot mode. mode=shadow accepted from any
  boot that has a dispatcher mounted (shadow or edge). mode=edge still
  requires boot=edge (safety invariant). off and full still 409 for any
  PATCH (no dispatcher).
- Replaced _FLIPPABLE_BOOT_MODES with _DISPATCHER_BOOT_MODES and
  _EDGE_OVERRIDE_BOOT_MODES to make the two gates explicit.

Coordinator:
- _reconcile_from_hint now checks whether the hint's target mode can
  safely take effect on this deployment. Incompatible hints (e.g. a
  former edge-boot pod's hint replayed on a shadow-boot deploy) are
  discarded and boot_reconcile_status is set to the new INCOMPATIBLE_HINT
  value, surfaced via /health. Without this guard, shadow-boot pods
  would boot with override=edge in state while the transport layer
  refused to honor it — misleading diagnostics that required the
  escape-hatch PATCH to clear.
- Added _deployment_allows_mode helper that mirrors the safety-invariant
  checks in version.py (lazy-imported to avoid the version→settings cycle).

Tests:
- test_patch_mcp_mode_shadow_clears_stale_override_on_shadow_boot
  exercises the escape hatch end-to-end.
- test_reconcile_from_hint_discards_incompatible_mode verifies the
  coordinator refuses to apply a shadow-boot-incompatible hint.
- test_reconcile_from_hint_accepts_shadow_mode_on_shadow_boot covers
  the positive case (shadow is always compatible).

Docs updated to explain the per-target-mode gating and the
INCOMPATIBLE_HINT status.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): discard stranded hints on boot=off and boot=full pods

Codex stop-time review caught that the previous _deployment_allows_mode
check returned True for shadow unconditionally — but boot=off has no
Rust sidecar at all and boot=full mounts a plain RustMCPRuntimeProxy
with no dispatcher, so neither boot mode has a mechanism that could
observe an override. A shadow hint replayed onto either pod would
strand in state (effective_mode=shadow in diagnostics; transport keeps
routing to its boot-determined target), and the router 409s for any
PATCH on those boots so the operator would have no API path to clear it.

Tighten _deployment_allows_mode to match the router's admit logic:
shadow accepts only when a dispatcher is mounted (boot=shadow or edge
for MCP; boot=shadow or edge for A2A), and edge still requires the
session-auth-reuse / delegate-enabled invariant.

Three new tests:
- test_reconcile_from_hint_discards_shadow_on_full_boot
- test_reconcile_from_hint_discards_any_mode_on_off_boot
- test_reconcile_from_hint_discards_a2a_hint_on_a2a_off_boot

Architecture doc updated to spell out all three INCOMPATIBLE_HINT cases.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): gate live pub/sub on compat check + structured rejection reasons

Third-pass review identified a critical and several important issues:

C1: _listen_loop applied remote pub/sub messages without the same
compatibility check _reconcile_from_hint enforces. A remote pod's
incompatible flip would strand in local state. Mirror the same gate.

I2: _listen_loop's recovery branch fired on any non-exception return,
including get_message returning None. A pubsub broken in a way that
returns None reliably without raising would falsely re-promote
cluster_propagation from DEGRADED to REDIS. Gate the recovery on
message is not None — the existing idle-timeout branch already handles
the legit no-message case.

I1: Document the deliberate "discarded hint left in Redis" choice in
_reconcile_from_hint's comment.

S4+S5+S6 (combined refactor):
- Promote _deployment_allows_mode to return MoveCompatibility enum
  with structured rejection reasons (NO_DISPATCHER, BOOT_FULL_STRANDS,
  EDGE_NEEDS_SAFETY_FLAG, OK).
- Split BootReconcileStatus.INCOMPATIBLE_HINT into three granular
  values (INCOMPATIBLE_NO_DISPATCHER, INCOMPATIBLE_BOOT_FULL,
  INCOMPATIBLE_SAFETY_FLAG) so /health distinguishes the cases.
- Move the helper to version.py next to the safety-invariant functions
  with a new public deployment_allows_override_mode wrapper.
- Router now calls deployment_allows_override_mode directly, replacing
  the parallel _DISPATCHER_BOOT_MODES/_EDGE_OVERRIDE_BOOT_MODES sets
  with a single source of truth. The 409 detail string is generated
  from the MoveCompatibility reason and names the exact env var or
  flag the operator needs to flip.

Tests added (I3 + I4 + I5 + S1 + S2):
- test_reconcile_from_hint_discards_edge_on_full_boot
- test_reconcile_from_hint_discards_edge_on_off_boot
- test_incompatible_hint_does_not_delete_redis_key_or_downgrade_propagation
  (S1 + S2: assert hint key untouched AND cluster_propagation stays REDIS)
- test_patch_mcp_mode_edge_409_when_boot_mode_full
- test_deployment_allows_override_mode_mcp_table (8 parametrized rows)
- test_deployment_allows_override_mode_a2a_table (6 parametrized rows)

Test fixtures (edge_boot, off_boot, full_boot, shadow_boot) now patch
the underlying settings instead of just monkeypatching the boot-mode
helpers — the new deployment_allows_override_mode inspects raw
settings, so fixture drift would have masked real bugs.

Architecture doc updated to enumerate the three INCOMPATIBLE_* values
and document the live pub/sub compat check.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): drop self-contradicting 'full' recommendation from NO_DISPATCHER 409

Codex stop-time review caught that the NO_DISPATCHER 409 detail told
operators "Boot with RUST_..._MODE='shadow' or 'edge' (or 'full' for
MCP) to enable overrides." But boot=full returns BOOT_FULL_STRANDS
for the same reason (no dispatcher mounted) — switching to full would
just trade one 409 for another. Worse, A2A has no 'full' mode at all,
so the parenthetical was incoherent for A2A 409 responses.

Drop the "(or 'full' for MCP)" clause. Add an inline comment explaining
why full is intentionally excluded so a future maintainer doesn't
re-add it. Two existing tests now also assert "'shadow' or 'edge'" is
present and "'full'" is absent — pins the contract.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(secrets): refresh detect-secrets baseline after rebase onto main

Line numbers in .secrets.baseline are tied to file content. After
rebasing this branch onto main (which itself touched .secrets.baseline
in upstream commits), the baseline needed a re-scan to match the
post-rebase final file state. No new secrets detected.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): defer generic cluster-wide settings propagation framework (ADR-050)

Records the decision to keep the runtime-mutable mode override
implementation purpose-built for MCP/A2A rather than extracting a
generic ClusterStatePrimitive framework today. Captures the alternative
abstraction sketch for future reference and enumerates the trigger
conditions that would justify revisiting (second concrete consumer in
flight, third independently-tracked use case on the horizon, or a need
for cross-consumer coordination like atomic multi-setting flips).

This is N=1 — CLAUDE.md's "three similar lines is better than a
premature abstraction" applies. The right shape will emerge from
copy-modifying for the second consumer and diffing the result.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): scrub AI-assistant name from ADR-050

Repo guideline forbids naming AI assistants in committed diffs. The
rationale section in ADR-050 cited 'Codex' as the source of stop-time
review iterations; rephrase to describe the review process without
naming the tool. Also drop the explicit 'CLAUDE.md' file citation in
favor of 'repo convention' since the supporting quote stands on its
own.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): tighten ADR-050 claims, deliver defenses checklist, add 0049 row

Comment-analyzer and code-reviewer review of ADR-050 surfaced several
claims that would rot, one promised section that didn't exist, and a
missing index row that visually framed ADR-050 as having skipped a
number.

ADR-050 fixes:

- Correct the "≈900 LOC together" claim to ~1.5 kLOC. Actual line counts
  are runtime_state.py 977 + runtime_admin_router.py 489 = 1,466.
- Replace "six stop-time review iterations" with the verifiable "five
  follow-up fix(runtime): commits on top of the initial feat(runtime):"
  so the count is grep-checkable from git log.
- Drop the "repo convention is explicit: three similar lines beats a
  premature abstraction" framing — that line is from contributor tooling,
  not anything written in the repo. The N=1 argument now stands on its
  own merits.
- Replace "the most-reviewed code on the branch" superlative with the
  timeless "received unusually heavy review during initial development."
- Caption the ClusterStatePrimitive code block as a hypothetical sketch
  so readers who grep-arrive mid-document don't think the class exists.
- Move the reverse-proxy WARN attribution from runtime_state.py to
  runtime_admin_router.py (it lives in the router).
- Name LISTEN_LOOP_DEGRADE_THRESHOLD = 5 and
  RUNTIME_STATE_HINT_TTL_SECONDS = 24 * 60 * 60 in the context section
  so future drift in those constants is detectable from this ADR.
- Reconcile trigger-#1's "build both consumers side-by-side" with the
  rationale's "copy-modify" guidance: copy-modify first, then extract
  the framework from the resulting two-implementation diff.
- Deliver the "non-obvious defenses" checklist that the Negative
  consequence promised but never wrote. The new "Defenses checklist for
  the copy-modify path" section enumerates 12 defenses, each tied to a
  bug class caught during this PR's review iterations.

Index fix:

- Add the missing 0049 row for "Multi-Protocol Virtual Servers"
  (Accepted, Architecture, 2026-04-16). The 048→050 jump made ADR-050
  look like it had skipped a number when ADR-049 was just un-indexed.
- Other duplicate ADR numbers on disk (005, 034, 038, 048) and other
  index gaps are pre-existing and tracked separately in #4290.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(transports): swappable MCPIngressMount + nginx-style Rust public proxy (ADR-051)

Replace MCPStreamableHTTPModeDispatcher (closed-set, two transports,
policy baked into dispatch) with MCPIngressMount: a thin ASGI
indirection that holds a registry of named ingresses + a swappable
selector. Adding a new ingress is one register() call; selection
policy is one function.

Three ingresses registered today:

- "python" — the existing MCPRuntimeHeaderTransportWrapper. Always
  registered.
- "rust-internal" — the existing RustMCPRuntimeProxy that forwards
  via httpx to MCP_RUST_LISTEN_HTTP / UDS (trusted-internal listener).
  Registered for boot=shadow/edge.
- "rust-public" — new RustMCPPublicProxyApp that does nginx-style
  reverse-proxy to MCP_RUST_PUBLIC_LISTEN_HTTP. Adds X-Forwarded-*,
  RFC 7239 Forwarded, X-Real-IP. Streams both directions, preserves
  Authorization/Cookie. Registered only for boot=edge.

Selector chooses between rust-internal and rust-public based on the
new settings.mcp_rust_ingress (default "internal" preserves prior
behavior bit-for-bit). Selector preserves all existing safety
invariants — _should_mount_public_rust_transport() is consulted first,
shadow override / boot=full / boot=off behave identically to before,
the runtime-mode override admin API requires no changes.

Test results: 1839 passing across the touched modules. Lint clean.

ADR-051 documents the design decision, alternatives considered, and
migration notes from MCPStreamableHTTPModeDispatcher. Index updated.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transports): close XFF spoof + harden Rust public proxy ingress

Response to review of 19677706b (MCPIngressMount + nginx-style Rust
public proxy). Closes a security issue and several diagnostic gaps in
the public proxy and ingress selector.

Security fix — RustMCPPublicProxyApp:

- _build_forwarded_headers now drops client-supplied Forwarded /
  X-Forwarded-* / X-Real-IP from the inbound iteration before
  re-deriving them from request.client. The prior code appended to
  the client-supplied X-Forwarded-For, letting any caller spoof their
  own source IP to Rust's /_internal/mcp/authenticate callback in the
  no-nginx-in-front deployment this proxy targets. New
  _FORWARDED_CHAIN_HEADERS frozenset enumerates the strip set.
- _format_forwarded_for emits RFC 7239 §6.3-conformant `for=unknown`
  (no synthetic `:0`) for absent clients, brackets IPv6 addresses
  exactly once, and tolerates already-bracketed input.

Selector hardening — _select_mcp_ingress / config:

- mcp_rust_ingress is now Literal["internal", "public"] so Pydantic
  rejects typos at boot.
- Selector only returns "rust-public" when boot_mcp_runtime_mode() ==
  "edge" — the public listener isn't bound on shadow boot, so a stale
  edge override + mcp_rust_ingress=public combination would otherwise
  return an unregistered name and silently fall back to Python at
  debug-only log severity.
- _build_mcp_transport_app emits a logger.error (not warning) when
  mcp_rust_ingress=public is set on shadow boot, so the misconfig
  survives the project-default LOG_LEVEL=ERROR.

Error handling — RustMCPPublicProxyApp:

- __call__ now distinguishes asyncio.CancelledError (re-raised),
  httpx.HTTPError (logged with type name + 502), and any other
  exception (logger.exception + 500), so a non-HTTPError no longer
  bypasses the handler and produces a silent Starlette 500.
- _body_iter logs CancelledError at debug (common for SSE client
  disconnect) and other exceptions at error (rare upstream death),
  before re-raising. The finally still calls aclose to return the
  upstream connection to the pool.
- Auth-relevant or server-error upstream statuses (401/403/5xx) now
  log a warning with method+path+status so a credential-rotation flood
  is visible without raising the global log level.

MCPIngressMount diagnostics:

- dispatch's selector-miss paths (fallback used, 503-on-no-fallback)
  now log warning with the unknown name AND self.names(), instead of
  debug-only on the fallback path with no log on the 503 path. A
  misrouted ingress should never be silent.

Tests:

- New tests/unit/mcpgateway/transports/test_rust_mcp_public_proxy.py
  (24 tests). Covers _format_forwarded_for parametrized over IPv4
  with/without port, IPv6, already-bracketed IPv6, None/empty host;
  _build_forwarded_headers spoof rejection + hop-by-hop strip + auth
  preservation + unknown-client emission; proxy ASGI behavior across
  status passthrough, 502 on httpx.HTTPError, 500 on unexpected,
  parametrized warning on (401, 403, 500, 503), non-HTTP scope → 404,
  CancelledError re-raise on send and mid-stream debug-branch, lazy
  client lifecycle, multi-chunk SSE streaming, and aclose-on-exit.
- Two new tests in test_mcp_ingress_mount.py pin the warning-level
  log severity on selector-miss + 503 paths.
- Two new tests in test_main_extended.py pin the boot-time error log
  for the shadow + mcp_rust_ingress=public misconfig and the selector
  downgrade to rust-internal under the same condition.

Doc rot fixes:

- mcp_ingress_mount.py module docstring uses the full ADR filename
  (was an ellipsis).
- version.py docstring references MCPIngressMount instead of the
  removed MCPStreamableHTTPModeDispatcher.
- ADR-051 migration notes use test-group descriptors instead of
  pinned line numbers / test names.

Test results: 652 passing across the touched modules. Lint clean
(black, ruff, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(quality): fix pylint W0706/W2301/R0401 and close coverage gaps

PR review feedback:

Pylint findings:

- W0706 (try-except-raise) at runtime_state.py:933 and
  rust_mcp_public_proxy.py:237: removed the redundant
  ``except asyncio.CancelledError: raise`` blocks. CancelledError is
  BaseException in Python 3.8+, so the surrounding ``except Exception``
  / ``except httpx.HTTPError`` arms never caught it; the explicit
  re-raise was no-op and made pylint complain. Behavior is unchanged
  (asyncio still cancels both coroutines naturally), and the intent is
  preserved as a comment.
- W2301 (unnecessary-ellipsis) at mcp_ingress_mount.py:37: dropped the
  ``...`` from the ASGIApp Protocol's ``__call__`` body — the
  docstring already counts as a body for Protocol methods.
- R0401 (cyclic-import) on the runtime_state ↔ version pair: added
  per-line ``# pylint: disable=cyclic-import`` to the two lazy imports
  in runtime_state.py. Pylint follows function-body imports for cycle
  detection; the lazy imports break the runtime cycle but pylint still
  flags the static graph. The full extract (move MoveCompatibility +
  coercers to a shared module) is bigger refactor than this PR
  warrants.

Coverage gaps closed (per-file delta):

- routers/runtime_admin_router.py 97.3% → 100%. Added three tests for
  ``_warn_if_behind_reverse_proxy``: header detection emits warning
  with header names, no warning when no forwarded headers, defensive
  early return when ``request.headers`` is None.
- transports/mcp_ingress_mount.py 94.7% → 100%. Added test for
  ``set_fallback`` covering swap mid-life and ``None`` restoring
  503-on-miss behavior.
- transports/rust_mcp_public_proxy.py 96.5% → 100%. Added test for
  ``_body_iter`` taking the ``except Exception`` arm on a non-cancel
  mid-stream error: asserts ``logger.exception`` fires with traceback.
- runtime_state.py 89.9% → 99%. Added 9 tests covering: defensive
  ValueError branches in 4 accessor methods (parametrized), idempotent
  ``coordinator.start()``, ``_reconcile_from_hint`` no-redis and
  malformed-mode paths, ``_cleanup_pubsub`` timeout / exception /
  fallback-to-close paths, listen-loop bytes-payload decode + idle
  recovery + malformed-payload + empty-data + uncoerceable-fields
  branches.
- version.py: targeted user-noted gaps (379, 434, 509, 570, 623) all
  closed. Added tests for last_change population in
  _mcp_runtime_status_payload / _a2a_runtime_status_payload, A2A
  shadow override forcing _should_delegate_a2a_to_rust False, and
  the boot_mcp_transport_mount public wrapper. Line 509
  (NO_DISPATCHER fallthrough — _coerce_runtime rejects unknown kinds
  upstream) marked ``# pragma: no cover`` as a defensive default.

Other defensive defaults marked ``# pragma: no cover``:

- runtime_admin_router.py:99 (OK fallthrough — caller already returned
  for OK upstream).
- runtime_state.py:164, 171 in ``_move_compat_to_reconcile_status``
  (OK case shouldn't reach here per docstring; final return is a
  defensive default for future MoveCompatibility variants).

Test results: 1272 passing across the touched modules. Lint clean
(black, ruff, pylint 10.00/10, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* build(container): consolidate on Containerfile.lite; deprecate others (#4297)

Closes #2613.

The top-level `Containerfile` duplicated the .lite build with weaker layer
caching and shipped build tooling in the runtime image. `Containerfile.scratch`
had no frontend-builder stage (admin UI bundle absent), failed under QEMU
(amd64-only), and had no consumer in CI, docs, or the Makefile. Both are
removed; `Containerfile.lite` is now the only canonical gateway image.

Apply the same layer-consolidation and caching pass across `Containerfile.lite`
and the per-component Containerfiles:

- Merge adjacent RUN steps (microdnf + ln + useradd; profile.d setup + chmod;
  Rust MCP + A2A cargo builds; ...) to reduce layer churn.
- Reorder COPYs from stable to volatile so frontend/Rust-binary changes no
  longer invalidate the heavy venv-install layer.
- Use COPY --chown=1001:0 in the UBI nodejs frontend-builder stage — fixes
  EACCES on package-lock.json under the non-root UBI default user — and
  COPY --chmod=0755 on run scripts to drop follow-up chmod layers.
- Replace \`npm install --frozen-lockfile\` (a yarn/pnpm flag npm silently
  ignores) with \`npm ci\`.
- Silence pre-existing hadolint DL3013/DL3041 findings with targeted ignore
  comments consistent with the file-header policy.

Update every touchpoint of the deprecated images: Makefile podman/docker[-dev]
targets, fly.toml, docker-compose commented migration examples, the
docker-scan workflow matrix and trigger paths, .travis.yml,
.pre-commit-config.yaml, .bumpversion.cfg, MANIFEST.in, and user-facing docs
(packaging, release-management, compose, openshift, container, fly-io, argocd
tutorial, MULTIPLATFORM, roadmap). Update the docker-scan unit test to match
the trimmed workflow. Incidentally fix the stale \$(CONTAINERFILE) typo in
the snyk-iac Makefile target.

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat: add HCS-14 UAID support for A2A agents (#4125)

* add HCS-14 UAID support for A2A agents

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* migration

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* lint error fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test case fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* pyporject.toml

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test coverage

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat: add base58 dependency and UAID routing config
  - Add base58>=2.1.1 dependency for UAID generation support
  - Add uaid_allowed_domains config setting for cross-gateway routing security
  - Document UAID_ALLOWED_DOMAINS in .env.example
  Related to #3956

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* a2a_agent_id column size change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat: add UAID support to Admin UI
  - Add UAID generation checkbox to create/edit A2A agent forms
  - Add UAID registry and protocol configuration fields
  - Display UAID vs UUID badge in agents table
  - Show UAID metadata section in agent view modal
  - Add JavaScript toggle logic for UAID fields
  - Update form population for editing UAID agents
  - Rebuild UI bundle with UAID changes
  Completes HCS-14 UAID feature with full UI support.
  Related to #3956

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid ui change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* revert id column length

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid in admin ui create form

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* edit form UAID

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* length change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* Add security documentation for UAID-based cross-gateway routing feature

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* documentation

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test case

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test: Add coverage for UAID cross-gateway routing edge cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* alembic head

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test coverage

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* base58 and its deps resolved

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security: fix SSRF and domain allowlist bypass in UAID cross-gateway routing

Fixes two critical security vulnerabilities in UAID-based cross-gateway routing:

1. Domain Allowlist Bypass (CVE-level)
   - Previous: endpoint.endswith(d) allowed "evilexample.com" to bypass "example.com" allowlist
   - Fixed: Require exact match OR proper subdomain prefix (.example.com)
   - Now correctly blocks: "evilexample.com", "notexample.com"
   - Still allows: "gateway.example.com", "example.com", "sub.gateway.example.com"

2. SSRF Injection via nativeId (CVE-level)
   - Previous: nativeId field directly interpolated into URL without validation
   - Attack vectors blocked:
     * Protocol injection: file:///etc/passwd, gopher://internal
     * User-info bypass: evil@127.0.0.1 (routes to internal IP)
     * Path injection: gateway.com/admin (access internal endpoints)
   - Legitimate ports still allowed: gateway.example.com:8443

Security validations added before URL construction:
- Reject protocol prefixes (://)
- Reject @ character (user-info bypass)
- Reject path components (/)
- Validate hostname format via urlparse
- Extract domain from endpoint:port for allowlist checking

Test coverage (8 new tests):
- test_invoke_remote_agent_domain_allowlist_bypass_attack
- test_invoke_remote_agent_domain_allowlist_subdomain_valid
- test_invoke_remote_agent_domain_allowlist_exact_match
- test_invoke_remote_agent_ssrf_protocol_injection
- test_invoke_remote_agent_ssrf_userinfo_bypass
- test_invoke_remote_agent_ssrf_path_injection
- test_invoke_remote_agent_ssrf_internal_ip (documents behavior)
- test_invoke_remote_agent_port_allowed

Related: #3956

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* style: apply black and isort formatting to schemas and UAID tests

- black: Fix line length in PluginPolicyItem config field description
- isort: Move First-Party imports to correct section in test files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid split

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* ruff

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security test cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* varibale correct

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* ruff

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* rebase fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat(a2a): A2A-1.0 UAID path in Rust sidecar + federation-loop guard

Splits UAID cross-gateway routing between the two runtimes by intent:
the Python gateway keeps the ContextForge-to-ContextForge federation
shape (target `/a2a/{uaid}/invoke` on a peer CF gateway), and the Rust
a2a_runtime gets a first-class A2A-1.0-compliant path that POSTs the
original JSON-RPC 2.0 body directly to the agent URL encoded in the
UAID's `nativeId`. The two paths are intentionally not reverse-compatible.

Key pieces:

- `crates/a2a_runtime/src/uaid.rs` (new): HCS-14 parser with SSRF
  rejection (user-info, path injection, unsupported scheme),
  case-insensitive domain allowlist (exact or `.suffix`), DoS length
  cap, sealed `RoutingTarget` with private fields and a `Proto` enum
  (`FromStr` + exhaustiveness test), stable `UaidError::code()` for
  structured logs.
- `crates/a2a_runtime/src/server.rs`: UAID dispatch in
  `handle_a2a_invoke` with local-resolve-first and cross-gateway
  fall-through; SSE proxy (`handle_uaid_cross_gateway_streaming`)
  with Last-Event-ID replay and a bounded JSON fallback that enforces
  `max_response_body_bytes` via a streaming `read_bounded_body` helper
  (per-chunk + cumulative cap); startup validation and rate-limited
  warning for an empty allowlist; explicit one-time security warning
  for the unforwarded-auth tradeoff.
- `mcpgateway/main.py` + `mcpgateway/services/a2a_service.py`: kind-
  dispatch resolve (name/id/uaid) with 403 for visibility-denial so
  the sidecar can distinguish hidden-from-absent; URL-encoded UAID in
  the CF-federation target path; dropped redundant `agent_id` body
  field; structured error logging without leaking 5xx internals.

Federation-loop prevention covers both A->B->A and self-referential
`endpoint_url` loops:

- `X-Contextforge-UAID-Hop: N` counter replaces the earlier binary
  marker. Every outbound (cross-gateway, local-agent, and the
  backward-compat `POST /invoke`) stamps `N+1`; every inbound rejects
  at `uaid_max_federation_hops` (default 5).
- Strict parser with byte-identical rules in both runtimes: pure
  ASCII digits only (no Unicode digits), no whitespace on single
  values, saturating at `2**31 - 1`; RFC 7230 section 3.2.2 coalesced
  form `"0, 10"` parses each element with OWS trim and takes the max
  so smuggled low values can't mask a real high value.
- Max-of-variants defense against header-case smuggling: `HashMap`
  iteration order can't be used to select a low value paired with a
  high one, and axum's multi-valued `HeaderMap` is drained via
  `get_all` + max.
- Named-agent paths translate resolve's 403 to 404 at the axum edge
  so private-agent existence never leaks through the status code or
  the error text (`map_agent_resolve_err` helper).

Config additions:

- Python `uaid_allowed_domains` (list), `uaid_max_length` (2048),
  `uaid_max_federation_hops` (default 5).
- Rust `A2A_RUST_UAID_ALLOWED_DOMAINS`, `A2A_RUST_UAID_MAX_LENGTH`,
  `A2A_RUST_UAID_MAX_FEDERATION_HOPS` (same defaults).

Tests:

- Python unit/integration: kind-dispatch resolve (uaid/id/name fall-
  back), parse_hop_count parametric cases including Unicode digits,
  whitespace, overflow, coalesced RFC 7230 form and smuggling, local-
  agent hop stamping on `_execute_agent_request`, 3-hop chain locking
  in the stamp-N+1 vs reject-at-max contract, TestClient-level
  `/a2a/{agent_name}/invoke` header plumbing.
- Rust integration: 12+ UAID dispatch tests (local hit, cross-gateway
  forward, streaming SSE proxy, body-size cap on chunked responses,
  per-chunk cap, hop-limit at max for UAID and plain names,
  multi-variant smuggling, coalesced header, 403->404 on named paths,
  case-insensitive `/invoke` hop guard).
- Rust uaid.rs: 16 unit tests covering parse/validate/SSRF/allowlist
  round-trip plus `Proto` exhaustiveness and `UaidError::code()`
  stability contracts.

Closes #3956

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Jonathan Springer <jps@s390x.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(#4205): isolate upstream MCP sessions per downstream session (#4299)

* feat(services): scaffold UpstreamSessionRegistry for 1:1 upstream binding (#4205)

Introduces UpstreamSessionRegistry, which maps
(downstream_session_id, gateway_id) to a single upstream MCP ClientSession.
Replaces the identity-keyed MCPSessionPool's sharing semantics: two
downstream MCP sessions — even carrying the same user identity — can no
longer receive the same pooled upstream session. This is the core
isolation change #4205's reproducer needs.

Design:
  - 1:1 per (downstream_session_id, gateway_id). Never shared.
  - Connection reuse preserved WITHIN one downstream session: a client
    making many tool calls against gateway G through session S still
    hits the upstream's initialize exactly once.
  - Health probe on reuse after idle_validation_seconds (60s default),
    chain is ping → list_tools → list_prompts → list_resources → skip.
    Failed probe recreates the upstream session.
  - Transport + ClientSession live inside a dedicated asyncio.Task whose
    anyio cancel scope is bound to that task, not to the request handler
    (#3737). Shutdown is signal-driven via an asyncio.Event; cancellation
    only as a last resort with a timeout.
  - No configurable settings. The tuning surface of the old pool
    (max_per_key, TTL, etc.) collapses under the 1:1 constraint.
  - Purely in-process. Multi-worker stickiness for a downstream session
    is the session-affinity layer's concern (to be extracted next) —
    each worker's registry only sees requests affinity has already
    routed to it.

API surface:
  - acquire() async context manager keyed by downstream_session_id,
    gateway_id, url, headers, transport_type. Rejects empty ids.
  - evict_session(downstream_session_id) for DELETE /mcp paths.
  - evict_gateway(gateway_id) for gateway rotation/removal.
  - close_all() for app shutdown.
  - snapshot() -> RegistrySnapshot for /admin and logs.
  - Module-level init/get/shutdown singleton accessors matching the
    shape of get_mcp_session_pool() so call-sites migrate cleanly.

Tests (16, all passing):
  isolation across downstream sessions (the #4205 invariant), reuse
  within a session, distinct upstreams per gateway for one session,
  concurrent acquires collapse to one create via the per-key lock,
  idle probe + reuse, failed probe + recreate, evict_session /
  evict_gateway / close_all teardown, dead owner-task detection,
  gateway-internal header stripping, and the singleton accessors.
  A FakeClientSession + fake SessionFactory keep the tests hermetic.

Not yet wired: nothing in the codebase calls the registry yet. The
follow-up commits will extract session-affinity to its own module,
wire startup/shutdown + DELETE eviction, migrate tool/prompt/resource
services, refactor gateway health checks, delete MCPSessionPool, and
remove the associated feature flags.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(services): wire UpstreamSessionRegistry into app lifecycle (#4205)

Adds startup init, shutdown drain, and DELETE-triggered eviction for
the registry introduced in the previous commit. All additive; the old
MCPSessionPool still runs alongside for now.

main.py:
  - init_upstream_session_registry() at startup, unconditionally (no
    feature flag — the registry is always on).
  - shutdown_upstream_session_registry() in the teardown block,
    ordered between pool shutdown and SharedHttpClient shutdown to
    ensure upstream sessions close before their HTTP transports go.

cache/session_registry.py (downstream session registry, distinct from
the upstream one this PR introduces):
  - remove_session() now calls get_upstream_session_registry().evict_session(id)
    as its last step. Fires on every path that drops a downstream session:
    explicit DELETE /mcp, internal /_internal/mcp/session DELETE, SSE
    disconnect housekeeping, database-backed session expiry. Wrapped so
    a missing singleton (tests, early shutdown) or an eviction exception
    never masks downstream teardown.

Tests (5 new, all passing):
  remove_session → evict_session forwarding; remove_session tolerating
  an uninitialized singleton; remove_session surviving an evict_session
  that raises; shutdown close_all() call + singleton clear; re-init
  after shutdown returns a fresh instance. Existing session_registry
  coverage tests still green (72 tests, no regressions).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(tool_service): route MCP tool calls through UpstreamSessionRegistry (#4205)

Both MCP call-sites in tool_service.invoke_tool (SSE at ~L5048, StreamableHTTP
at ~L5230) now acquire their upstream ClientSession from the registry when a
downstream Mcp-Session-Id is in scope. This is the first visible behavioural
change of the #4205 fix: two downstream MCP sessions served by the same user
now build SEPARATE upstream sessions, so stateful upstream servers (counter,
etc.) no longer leak state between downstream clients.

Changes:
  - Replace the conditional pool path with an unconditional registry path,
    gated on the presence of a downstream Mcp-Session-Id (read from the
    transport's request_headers_var via a new _downstream_session_id_from_request
    helper) AND a gateway_id AND not tracing_active.
  - Drop the `settings.mcp_session_pool_enabled` check at the call-sites.
    The registry is always on; its applicability is determined by whether a
    downstream session id is in scope.
  - Keep the per-call fallback path for callers without a downstream
    session id: admin UI test-invoke, internal /rpc, and anything that
    drives the tool_service outside the streamable-http transport.
  - Preserve the tracing trade-off: when tracing_active, skip the registry
    to allow per-request traceparent/X-Correlation-ID injection.
  - Remove the now-unused `get_mcp_session_pool, TransportType` import from
    mcp_session_pool; TransportType is now imported from the registry module.
  - _downstream_session_id_from_request uses a lazy import of
    streamablehttp_transport.request_headers_var to avoid a circular
    dependency at module load time.

Tests:
  - NEW test_invoke_tool_mcp_two_downstream_sessions_hit_registry_with_distinct_ids:
    the direct-consequence #4205 test — two invocations with different
    downstream session ids must produce two acquire() calls with distinct
    (session_id, gateway_id) keys, same gateway, different sessions.
  - Rewrote test_invoke_tool_mcp_pooled_path_does_not_inject_trace_headers
    as the equivalent registry-path test (same invariant: reused upstream
    transports must not receive per-request trace headers).
  - Rewrote 4 pool-hit tests in test_tool_service_coverage.py to use the
    registry API (and set request_headers_var with a session id).
  - 870 related tests pass; no regressions.

This migration leaves the old pool in place — it simply isn't called from
tool_service anymore. Prompt/resource/gateway call-sites still point at the
pool and will migrate in the next commits.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(prompts, resources): route MCP calls through UpstreamSessionRegistry (#4205)

Mirrors the tool_service migration: prompt_service and resource_service acquire
their upstream MCP ClientSession from the registry when a downstream Mcp-Session-Id
is in scope, falling back to per-call sessions otherwise. Same 1:1 isolation,
same connection reuse within a session, same trace-header trade-off.

Changes:
  - prompt_service._fetch_prompt_from_gateway: replace pool path with registry
    path; drop the `settings.mcp_session_pool_enabled` gate; drop the now-unused
    `pool_user_identity` local.
  - resource_service.invoke_resource SSE + StreamableHTTP helpers: same
    rewrite. Also delete the `pool_user_identity` normalization block at
    line ~1708 (no longer referenced).
  - upstream_session_registry: add `downstream_session_id_from_request_context()`
    so the three services share one implementation. tool_service now aliases
    the shared helper rather than carrying its own copy.
  - TransportType is imported from upstream_session_registry in all three
    services; the pool's copy becomes unused and disappears in the
    hollow-and-rename step.

Tests:
  - Rewrote 4 pool-hit tests in test_resource_service.py as registry-path
    tests (set request_headers_var with a downstream session id, patch
    get_upstream_session_registry). Renamed for accuracy:
    test_sse_session_pool_used_and_signature_validated →
      test_sse_registry_used_and_signature_validated
    test_invoke_resource_streamablehttp_uses_session_pool_when_available →
      test_invoke_resource_streamablehttp_uses_registry_when_available
    The two "pool not initialized falls back" tests now simulate an
    uninitialized registry via RuntimeError from get_upstream_session_registry.
  - 1173 service-layer tests pass; no regressions.

Two holdouts still touch the pool: gateway_service's health check (task #19)
and the pool itself (task #23 deletes its upstream-session code and renames
the file to session_affinity.py).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* refactor(gateway_service): drop pool from health checks (#4205)

Gateway health checks are system operations — no downstream MCP session id
is in scope — so they can't key the UpstreamSessionRegistry. They also
don't benefit meaningfully from connection reuse: each probe is a one-shot
initialize round-trip to detect reachability. Use a fresh per-call session
unconditionally.

Changes:
  - _check_single_gateway_health (streamablehttp branch): replaces the
    pool-or-fallback block with a single streamablehttp_client +
    ClientSession per probe.
  - Drop the `mcp_session_pool_explicit_health_rpc` feature flag usage
    (the setting itself disappears in the config cleanup commit). The
    initialize() round-trip is the probe; no optional list_tools() needed.
  - Imports: drop `get_mcp_session_pool, TransportType` from the pool
    import; keep `register_gateway_capabilities_for_notifications`
    (still used by three other call-sites in gateway_service). Also drop
    the now-unused `anyio` import (was used only for the pool branch's
    fail_after on list_tools).

Tests:
  - Rewrote test_streamablehttp_pool_not_initialized_falls_back_to_per_call_session
    as test_streamablehttp_health_uses_per_call_session (since per-call is
    now unconditional, that's the whole behavior the test pins).
  - Deleted test_streamablehttp_pool_used_and_explicit_health_rpc_calls_list_tools
    (exercised a code path that no longer exists).
  - Deleted tests/unit/mcpgateway/services/test_gateway_explicit_health_rpc.py
    entirely — it only tested the MCP_SESSION_POOL_EXPLICIT_HEALTH_RPC
    feature flag's on/off branches.
  - 168 gateway_service tests pass; no regressions.
Signed-off-by: Jonathan Springer <jps@s390x.com>

* refact…
brian-hussey added a commit that referenced this pull request May 20, 2026
…ad_only, tmpfs) (#4469)

* chore: security hardening for startup logs (#4507)

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* [chore] Release version 1.0.0 (#4551)

* Update reference from tools_rust to crates in bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update version in root Cargo.toml file and reference in .bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update secrets

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update base image versions

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Corrected version of UUI dependency

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Linting updates to requirements.txt files

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update front end dependencies

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update npm deps and black finding

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* License updates and linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update to not fail on test setup

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release PII tool invocation steps (#4521)

* docs: fix release PII tool invocation steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: align release PII verification steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: fix release docs build links

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: scope PII release check to tool hooks

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* Revert "docs: fix release docs build links"

This reverts commit 88c4b7b3d1c4e462d1e06e329ab4aff8e13eec42.

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: use admin token for plugin status check

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: run discovered PII filter tests

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: remove PII unit test step

Signed-off-by: lucarlig <luca.carlig@ibm.com>

---------

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release docs build links (#4523)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update CHANGELOG with PR 4507 details

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update reference material

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Alembic linearity fix (#4547)

* Alembic linearity fix

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: handle ix_/idx_ index name mismatch in migration downgrades

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update alembic migration tools

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(alembic): drop orphaned ix_token_revocations_token_expiry on downgrade

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Final Update to CHANGELOG.md

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Address final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update cargo package details for cargo vet check

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct test coverage within previously cleaned code audit_trail_service

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Luca <luca.carlig@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Fix/alembic migration safety (#4479)

* fix(alembic): safe downgrade + roundtrip CI validation

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(alembic): add migration_metadata table for hermetic downgrade config snapshots

Introduces a new migration (f9a8b7c6d5e4) that creates a
migration_metadata(revision, key, value, created_at) table. Data
migrations that read settings.* at upgrade time now write a config
snapshot into this table so that downgrade() can use the original
values regardless of the current environment.

ba202ac1665f (migrate_user_roles_to_configurable_defaults) is wired
to this table:
- upgrade(): snapshots default_admin_role, default_user_role,
  default_team_owner_role, default_team_member_role immediately after
  reading them from settings.
- downgrade(): reads the snapshot first; falls back to live settings
  only if the table/rows are absent, logging a warning.

scripts/ci/run_upgrade_validation.sh: replace post-downgrade strict
equality assert with assert_version_present. The migration graph has a
parallel branch (aa1b2c3d4e5f) that diverges below the base-image
head; Alembic correctly leaves that branch tip in alembic_version after
downgrade. assert_version_present verifies the expected version is
present and logs the extra heads rather than failing.

All 6 CI checks pass (SQLite fresh, SQLite upgrade, PostgreSQL fresh,
PostgreSQL upgrade, SQLite roundtrip, PostgreSQL roundtrip).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore(make): add migration-test-rollback target

Adds a dedicated `make migration-test-rollback` Makefile target that
runs pytest -k 'reverse or rollback' on both SQLite and PostgreSQL test
files, followed by the full upgrade/downgrade roundtrip harness
(scripts/ci/run_upgrade_validation.sh).

Also updates the upgrade-validate help comment to reflect that it now
validates fresh, upgrade, AND roundtrip (downgrade) paths, since
run_sqlite_roundtrip/run_postgres_roundtrip were added in the previous
commit.

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* test(migration): add cross-DB schema consistency test

Adds tests/migration/test_cross_db_schema_consistency.py, a new pytest
module that boots the target image twice — once with SQLite, once with
PostgreSQL — applies Alembic migrations to head in each case, then
introspects both schemas via SQLAlchemy's Inspector API (exec'd inside
the running container) and asserts:

  test_same_tables_exist      - identical table set (66 tables)
  test_column_names_match     - same column names per table
  test_column_types_match     - equivalent types after canonicalisation
  test_primary_keys_match     - same primary key columns
  test_foreign_keys_match     - same FK relationships

Engine-specific type differences (VARCHAR vs TEXT, TIMESTAMPTZ vs
DATETIME, JSONB vs JSON, BYTEA vs BLOB, etc.) are normalised through a
_TYPE_CANON mapping so the test asserts semantic equivalence, not
string identity.  Unknown type mappings produce a clear failure message
directing the author to extend _TYPE_CANON.

Also adds make migration-test-cross-db, a dedicated Makefile target.

Verified locally: 5/5 tests pass in 22 s against
mcpgateway/mcpgateway:upgrade-ci-test (67 tables on each engine).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* docs: document migration CI coverage decision and config snapshot pattern

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(make): fix tab indentation in migration-test-rollback and migration-test-cross-db targets

Makefile recipe lines were indented with 8 spaces instead of tabs,
causing 'missing separator' parse errors that triggered the Rust CI
workflow (which monitors Makefile changes).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff errors in new migration files and cross-DB test

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff F841 and detect-secrets false positives

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix: address PR review feedback - migration safety hardening

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): remove extra blank lines in db.py (E303)

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update secrets baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* chore(deps): bump cpex-rate-limiter to 0.0.6 (#4596)

* chore(deps): bump cpex-rate-limiter to 0.0.6 (TLS support release)

Pulls in the TLS-support release published from cpex-plugins#74,
which enables the rate-limiter plugin to connect to managed Redis
endpoints that require TLS (`rediss://` URLs).  Operators with
managed Redis services that mandate in-transit encryption (AWS
ElastiCache with encryption enabled, Redis Cloud, IBM Cloud
Databases for Redis, etc.) can now point the plugin at those
endpoints without tripping the prior `InvalidClientConfig: TLS
feature not enabled` plugin-init failure.

Three coordinated edits:

- ``pyproject.toml:15`` — bump uv ``exclude-newer`` date pin from
  ``2026-04-22T23:59:59Z`` (covered up to and including 0.0.4) to
  ``2026-05-31T23:59:59Z`` so uv sees the 0.0.6 wheel
  (uploaded 2026-05-05T08:13:13).
- ``pyproject.toml:281`` — bump min-version constraint
  ``cpex-rate-limiter>=0.0.4`` -> ``cpex-rate-limiter>=0.0.6`` so
  fresh installs pull the TLS-enabled wheel.
- ``uv.lock`` — regenerated via
  ``uv lock --upgrade-package cpex-rate-limiter`` to pin the
  resolved version + per-platform wheel hashes for 0.0.6.

Local-dev defaults unchanged.  ``docker-compose.yml``'s
``REDIS_URL=redis://redis:6379/0`` continues to work — TLS is
opt-in via a ``rediss://`` URL.

Verified locally: ``uv sync --extra plugins`` installs 0.0.6 cleanly,
``cpex_rate_limiter.rate_limiter.RateLimiterPlugin`` imports without
error, and the plugin-framework loader unit tests stay green.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

* chore(deps): align RateLimiterPlugin version field in plugins/config.yaml

The shipped ``plugins/config.yaml`` carried ``version: "0.0.3"`` for
``RateLimiterPlugin`` long after the published wheel had moved on.
Bumping to ``0.0.6`` to match the version the previous commit pulls
in (the TLS-support release from cpex-plugins#74).

This field is operator-facing metadata mirrored from the plugin's
own ``plugin-manifest.yaml``; it's not a dependency constraint, so
it does not affect resolution.  Updating it keeps the shipped
sample config truthful about which plugin version operators are
loading by default.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

---------

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat: Add populate-tiny to Makefile (#4563)

* feat: Add populate-tiny to Makefile

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Remove rc3 references (#4567)

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

Comment: By-passing checks as queue too long, this is low risk change.
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: document cargo-vet release workflow (#4600)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* bugfix/3825-playwright-htmx-dom-detach (#4264)

* fix: update scope['modified_path'] for server_id extraction with root_path (#4270)

Fixes #4266

The /mcp endpoint was returning ALL tools instead of server-scoped tools
when accessed via /servers/{id}/mcp in reverse proxy deployments with
root_path configured. The /sse endpoint worked correctly.

Root cause: MCPPathRewriteMiddleware computed app_path by stripping
root_path prefix (line 3026) but never updated scope['modified_path'].
When streamablehttp_transport.py:2898 reads modified_path to extract
server_id via regex (r'^/servers/(?P<server_id>[^/]+)/mcp'), the regex
fails to match paths like '/prefix/servers/123/mcp', causing server_id
to be None and tools/list to return all tools instead of server-scoped.

Solution: Update scope['modified_path'] = app_path after computing the
app-relative path. This ensures streamablehttp_transport receives the
path without root_path prefix for correct server_id extraction.

Changes:
- Set scope['modified_path'] to app_path after stripping root_path
- Add regression tests verifying modified_path is app-relative
- All existing MCPPathRewriteMiddleware tests pass

Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(transport): return 405 on session-less GET /mcp (#4205) (#4284)

* fix(docker): install wget in a2a-echo-agent image for healthcheck

The compose healthcheck for a2a_echo_agent runs `wget -qO- http://localhost:9100/health`,
but the debian:bookworm-slim runtime stage only installed ca-certificates and tzdata —
so every probe failed with "exec: wget: executable file not found in $PATH" and the
container stayed perpetually unhealthy despite the agent itself listening correctly.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): return 405 on session-less GET /mcp (#4205)

A strict MCP client opens a passive SSE stream via GET right after initialize
to receive server-initiated messages. When ContextForge cannot anchor that
stream to a session (stateful sessions disabled, session-core disabled in
Rust, or no Mcp-Session-Id presented), it previously either committed to an
empty event stream (Rust live-stream relay) or handed off to the SDK which
held the GET open without emitting events. Strict clients then timed out on
the empty stream and reported "Failed to initialize server session".

The MCP Streamable HTTP spec allows the server to return 405 for such GETs;
this change takes that route in both gateways.

Python (mcpgateway/transports/streamablehttp_transport.py):
  - SessionManagerWrapper.handle_streamable_http now returns 405 with
    Allow: POST, DELETE for GET when either settings.use_stateful_sessions
    is false or no Mcp-Session-Id was presented.
  - The check runs after _validate_server_id() and the RBAC gate so bogus
    server IDs still 404 and unauthorized callers still 403, preserving the
    "RBAC before processing" invariant.
  - Branch-specific detail messages distinguish "stateful sessions disabled"
    from "no session id presented" so clients don't retry in a loop.
  - logger.info on rejection with path + flags for ops visibility.

Rust (crates/mcp_runtime/src/lib.rs):
  - forward_transport_request returns 405 for GET when either
    session_core_enabled is false or no session id is presented in header
    or session_id query parameter. Without session_core, Rust cannot anchor
    an SSE stream; without a session id, there is nothing to anchor to.
  - The 405 response carries the same capability headers as every other
    terminal response in the function.
  - tracing::info! on rejection and a RuntimeStats.transport.get_rejected_no_session
    counter so ops can distinguish "no traffic" from "every client rejected".
  - One pre-existing test (server_scoped_transport_wrappers_inject_server_header)
    updated to provide a session id + record so it still exercises the
    server-id header injection path.

Tests:
  - Python: 3 new parametrized tests covering stateless/no-session/pass-through
    and both Mcp-Session-Id header aliases; server-scoped variant confirms
    405 fires only after server-id validation (and that 404 still wins for
    nonexistent servers).
  - Rust: 3 new tests covering 405 without session id, 405 when session_core
    disabled even with header, and live-stream branch still reached when a
    valid session id is presented.
  - Also un-hollows two pre-existing header-parsing tests
    (test_handle_streamable_http_non_tuple_header_skipped,
    test_handle_streamable_http_non_bytes_header_skipped) that would have
    been silently short-circuited by the new 405 path.

Note: This fixes the downstream GET-SSE cascade described in the comment
on #4205. The upstream per-client session-isolation problem from the
original reproducer (shared pooled upstream session leaking state between
downstream clients) is a separate architectural change and will be
addressed in a follow-up.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): address review feedback on GET /mcp 405 path (#4205)

Follow-up to 252e8c228. Addresses review feedback surfaced by the
PR-review agents:

- Rust `runtime_session_id_from_request` now also accepts
  `x-mcp-session-id`, restoring parity with the Python gateway so a
  client isn't accepted by one runtime and 405'd by the other.
- Split the Rust 405 counter: `get_rejected_no_session` stays as the
  total and a new `get_rejected_session_core_disabled` subcounter
  isolates the deployment-config branch, so ops can distinguish client
  misuse from session-core being off.
- Added a Python Prometheus counter (`transport_get_rejected_total`)
  with matching `no_session_id` / `stateful_disabled` outcomes to close
  the observability gap between runtimes.
- Split log levels on both sides: `warn` for the operator-facing
  disabled-session / stateful-disabled branch, `debug` for the
  no-session-id branch (routine strict-client probing).
- Added a regression guard test that GET with RBAC denial returns 403
  (not 405) and emits no `Allow` header.
- Session-affinity `except Exception` in the Python gateway now warns
  instead of silently rendering infrastructure failures as 405s.
- 404 on unknown server no longer allowed to leak an `Allow` header.
- GET OAuth-enabled server test asserts 401 before the 405 path.
- Rust happy-path tests snapshot counters to assert no 405 tick.
- "Why 405 (not 404/400)" rationale added to both comment blocks.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): gate Rust 405 inside live-stream branch (#4205)

Integration tests `get_and_delete_mcp_routes_forward_to_internal_transport_bridge`
and `live_stream_core_disabled_falls_back_to_python_transport_get` were
failing in CI: the standalone 405 block in `forward_transport_request`
pre-empted the Python transport-bridge fallback path, even for GETs the
runtime could have cleanly proxied.

The empty-stream cascade the 405 was defending against only arises when
the live-stream relay is entered without a session anchor. Move the 405
guard inside that branch so GETs that don't request SSE (or where
live_stream_core is disabled) fall through to the existing Python-proxy
fallback, which has its own 405 logic. Update the matching unit test to
set `Accept: text/event-stream` so it still reaches the gate.

No change to the counter semantics or Python-side behaviour. Full
workspace test suite (Rust + Python) passes.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(rust): apply clippy 1.95.0 lint fixes in non-PR crates

Rustc stable moved from 1.94.1 to 1.95.0 between the last main Rust CI
run (#3704, 2026-04-16) and PR #4284, enabling two additional lints at
workspace `-D warnings` level. The new lints flag pre-existing code in
crates outside this PR's surface area, blocking Rust clippy CI for every
branch that touches any Rust file.

Apply clippy's exact suggestions:

- `crates/wrapper/src/json_rpc_id_fast.rs`: collapse a `FieldName`
  match arm's nested `if` into a match guard (`collapsible_match`).
- `crates/a2a_runtime/src/event_store.rs`: drop a redundant
  `.into_iter()` in a `.zip()` argument (`useless_conversion`).
- `crates/a2a_runtime/src/server.rs`: collapse two match-arm nested
  `if`s (`get`|`cancel` and `list`) into match guards.

Verified with `cargo clippy --workspace --all-targets -- -D warnings`
and `cargo test --workspace` on rustc 1.95.0. No behaviour change.

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat(runtime): runtime-mutable RUST_MCP_MODE + A2A_MODE via admin API (#4296)

* feat(runtime): runtime-mutable MCP and A2A mode via admin API

Add PATCH /admin/runtime/mcp-mode and PATCH /admin/runtime/a2a-mode that
flip the public /mcp ingress and registered-A2A invocation path between
shadow and edge at runtime without a restart. Boot env vars
(RUST_MCP_MODE, RUST_A2A_MODE) still select the initial mode; overrides
are in-memory only. When Redis is configured the override propagates
cluster-wide via pub/sub on contextforge:runtime:mode with a per-runtime
monotonic INCR counter and a 24h hint key per runtime so freshly-started
pods reconcile to the cluster's current desired override on boot.

Off and full boot modes are intentionally not flippable (409): off has
no Rust sidecar to swap to; full keeps Rust as the owner of MCP session/
event-store/resume cores and flipping to shadow would orphan that state.

The reverse-proxy caveat (admin flips don't propagate past an upstream
nginx until the proxy is reconfigured) is documented and tracked
separately in #4278.

Closes #4273

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): preserve session-auth-reuse safety invariant under override

Codex stop-time review caught that `override="edge"` on a shadow-boot
deployment silently bypassed the documented safety invariant for routing
public traffic to Rust: `_should_mount_public_rust_transport` had been
loosened to require only `experimental_rust_mcp_runtime_enabled`, skipping
`experimental_rust_mcp_session_auth_reuse_enabled` (and the analogous
`delegate_enabled` for A2A). A boot=shadow deployment with an admin-issued
edge override would have started routing public MCP to a Rust sidecar
that never opted into session-auth-reuse.

Fix:
- `_should_mount_public_rust_transport` keeps the original invariant
  unchanged. Override="shadow" still forces Python; override="edge" now
  matches the default (honors the invariant). Same shape for
  `_should_delegate_a2a_to_rust`.
- Narrow `_FLIPPABLE_BOOT_MODES` to `{edge}`. Only boot=edge deployments
  have both flags true and can safely flip. boot=shadow PATCHes now
  return 409 with an operator-facing explanation of the invariant.
- Two belt-and-braces unit tests exercise the read side directly (even
  if a stale hint somehow landed override=edge in state, the transport
  function refuses to route to Rust without the safety flags).
- Two router tests cover the new 409 for shadow boot (MCP + A2A).
- Docs updated: architecture doc, ADR 043 addendum, crate README/STATUS,
  .env.example, docker-compose.yml.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): allow shadow override clearance and discard incompatible hints

Codex stop-time review caught a new dead-end introduced by the previous
safety-invariant fix: after restricting _FLIPPABLE_BOOT_MODES to {edge},
a shadow-boot deployment that inherited a stale override=edge via a
Redis hint (left by a prior edge-boot deploy) had no API path to clear
state — every PATCH returned 409. The operator's only recourse was
flushing Redis by hand.

Fix is two-sided:

Router:
- Gate on target mode, not boot mode. mode=shadow accepted from any
  boot that has a dispatcher mounted (shadow or edge). mode=edge still
  requires boot=edge (safety invariant). off and full still 409 for any
  PATCH (no dispatcher).
- Replaced _FLIPPABLE_BOOT_MODES with _DISPATCHER_BOOT_MODES and
  _EDGE_OVERRIDE_BOOT_MODES to make the two gates explicit.

Coordinator:
- _reconcile_from_hint now checks whether the hint's target mode can
  safely take effect on this deployment. Incompatible hints (e.g. a
  former edge-boot pod's hint replayed on a shadow-boot deploy) are
  discarded and boot_reconcile_status is set to the new INCOMPATIBLE_HINT
  value, surfaced via /health. Without this guard, shadow-boot pods
  would boot with override=edge in state while the transport layer
  refused to honor it — misleading diagnostics that required the
  escape-hatch PATCH to clear.
- Added _deployment_allows_mode helper that mirrors the safety-invariant
  checks in version.py (lazy-imported to avoid the version→settings cycle).

Tests:
- test_patch_mcp_mode_shadow_clears_stale_override_on_shadow_boot
  exercises the escape hatch end-to-end.
- test_reconcile_from_hint_discards_incompatible_mode verifies the
  coordinator refuses to apply a shadow-boot-incompatible hint.
- test_reconcile_from_hint_accepts_shadow_mode_on_shadow_boot covers
  the positive case (shadow is always compatible).

Docs updated to explain the per-target-mode gating and the
INCOMPATIBLE_HINT status.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): discard stranded hints on boot=off and boot=full pods

Codex stop-time review caught that the previous _deployment_allows_mode
check returned True for shadow unconditionally — but boot=off has no
Rust sidecar at all and boot=full mounts a plain RustMCPRuntimeProxy
with no dispatcher, so neither boot mode has a mechanism that could
observe an override. A shadow hint replayed onto either pod would
strand in state (effective_mode=shadow in diagnostics; transport keeps
routing to its boot-determined target), and the router 409s for any
PATCH on those boots so the operator would have no API path to clear it.

Tighten _deployment_allows_mode to match the router's admit logic:
shadow accepts only when a dispatcher is mounted (boot=shadow or edge
for MCP; boot=shadow or edge for A2A), and edge still requires the
session-auth-reuse / delegate-enabled invariant.

Three new tests:
- test_reconcile_from_hint_discards_shadow_on_full_boot
- test_reconcile_from_hint_discards_any_mode_on_off_boot
- test_reconcile_from_hint_discards_a2a_hint_on_a2a_off_boot

Architecture doc updated to spell out all three INCOMPATIBLE_HINT cases.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): gate live pub/sub on compat check + structured rejection reasons

Third-pass review identified a critical and several important issues:

C1: _listen_loop applied remote pub/sub messages without the same
compatibility check _reconcile_from_hint enforces. A remote pod's
incompatible flip would strand in local state. Mirror the same gate.

I2: _listen_loop's recovery branch fired on any non-exception return,
including get_message returning None. A pubsub broken in a way that
returns None reliably without raising would falsely re-promote
cluster_propagation from DEGRADED to REDIS. Gate the recovery on
message is not None — the existing idle-timeout branch already handles
the legit no-message case.

I1: Document the deliberate "discarded hint left in Redis" choice in
_reconcile_from_hint's comment.

S4+S5+S6 (combined refactor):
- Promote _deployment_allows_mode to return MoveCompatibility enum
  with structured rejection reasons (NO_DISPATCHER, BOOT_FULL_STRANDS,
  EDGE_NEEDS_SAFETY_FLAG, OK).
- Split BootReconcileStatus.INCOMPATIBLE_HINT into three granular
  values (INCOMPATIBLE_NO_DISPATCHER, INCOMPATIBLE_BOOT_FULL,
  INCOMPATIBLE_SAFETY_FLAG) so /health distinguishes the cases.
- Move the helper to version.py next to the safety-invariant functions
  with a new public deployment_allows_override_mode wrapper.
- Router now calls deployment_allows_override_mode directly, replacing
  the parallel _DISPATCHER_BOOT_MODES/_EDGE_OVERRIDE_BOOT_MODES sets
  with a single source of truth. The 409 detail string is generated
  from the MoveCompatibility reason and names the exact env var or
  flag the operator needs to flip.

Tests added (I3 + I4 + I5 + S1 + S2):
- test_reconcile_from_hint_discards_edge_on_full_boot
- test_reconcile_from_hint_discards_edge_on_off_boot
- test_incompatible_hint_does_not_delete_redis_key_or_downgrade_propagation
  (S1 + S2: assert hint key untouched AND cluster_propagation stays REDIS)
- test_patch_mcp_mode_edge_409_when_boot_mode_full
- test_deployment_allows_override_mode_mcp_table (8 parametrized rows)
- test_deployment_allows_override_mode_a2a_table (6 parametrized rows)

Test fixtures (edge_boot, off_boot, full_boot, shadow_boot) now patch
the underlying settings instead of just monkeypatching the boot-mode
helpers — the new deployment_allows_override_mode inspects raw
settings, so fixture drift would have masked real bugs.

Architecture doc updated to enumerate the three INCOMPATIBLE_* values
and document the live pub/sub compat check.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): drop self-contradicting 'full' recommendation from NO_DISPATCHER 409

Codex stop-time review caught that the NO_DISPATCHER 409 detail told
operators "Boot with RUST_..._MODE='shadow' or 'edge' (or 'full' for
MCP) to enable overrides." But boot=full returns BOOT_FULL_STRANDS
for the same reason (no dispatcher mounted) — switching to full would
just trade one 409 for another. Worse, A2A has no 'full' mode at all,
so the parenthetical was incoherent for A2A 409 responses.

Drop the "(or 'full' for MCP)" clause. Add an inline comment explaining
why full is intentionally excluded so a future maintainer doesn't
re-add it. Two existing tests now also assert "'shadow' or 'edge'" is
present and "'full'" is absent — pins the contract.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(secrets): refresh detect-secrets baseline after rebase onto main

Line numbers in .secrets.baseline are tied to file content. After
rebasing this branch onto main (which itself touched .secrets.baseline
in upstream commits), the baseline needed a re-scan to match the
post-rebase final file state. No new secrets detected.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): defer generic cluster-wide settings propagation framework (ADR-050)

Records the decision to keep the runtime-mutable mode override
implementation purpose-built for MCP/A2A rather than extracting a
generic ClusterStatePrimitive framework today. Captures the alternative
abstraction sketch for future reference and enumerates the trigger
conditions that would justify revisiting (second concrete consumer in
flight, third independently-tracked use case on the horizon, or a need
for cross-consumer coordination like atomic multi-setting flips).

This is N=1 — CLAUDE.md's "three similar lines is better than a
premature abstraction" applies. The right shape will emerge from
copy-modifying for the second consumer and diffing the result.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): scrub AI-assistant name from ADR-050

Repo guideline forbids naming AI assistants in committed diffs. The
rationale section in ADR-050 cited 'Codex' as the source of stop-time
review iterations; rephrase to describe the review process without
naming the tool. Also drop the explicit 'CLAUDE.md' file citation in
favor of 'repo convention' since the supporting quote stands on its
own.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): tighten ADR-050 claims, deliver defenses checklist, add 0049 row

Comment-analyzer and code-reviewer review of ADR-050 surfaced several
claims that would rot, one promised section that didn't exist, and a
missing index row that visually framed ADR-050 as having skipped a
number.

ADR-050 fixes:

- Correct the "≈900 LOC together" claim to ~1.5 kLOC. Actual line counts
  are runtime_state.py 977 + runtime_admin_router.py 489 = 1,466.
- Replace "six stop-time review iterations" with the verifiable "five
  follow-up fix(runtime): commits on top of the initial feat(runtime):"
  so the count is grep-checkable from git log.
- Drop the "repo convention is explicit: three similar lines beats a
  premature abstraction" framing — that line is from contributor tooling,
  not anything written in the repo. The N=1 argument now stands on its
  own merits.
- Replace "the most-reviewed code on the branch" superlative with the
  timeless "received unusually heavy review during initial development."
- Caption the ClusterStatePrimitive code block as a hypothetical sketch
  so readers who grep-arrive mid-document don't think the class exists.
- Move the reverse-proxy WARN attribution from runtime_state.py to
  runtime_admin_router.py (it lives in the router).
- Name LISTEN_LOOP_DEGRADE_THRESHOLD = 5 and
  RUNTIME_STATE_HINT_TTL_SECONDS = 24 * 60 * 60 in the context section
  so future drift in those constants is detectable from this ADR.
- Reconcile trigger-#1's "build both consumers side-by-side" with the
  rationale's "copy-modify" guidance: copy-modify first, then extract
  the framework from the resulting two-implementation diff.
- Deliver the "non-obvious defenses" checklist that the Negative
  consequence promised but never wrote. The new "Defenses checklist for
  the copy-modify path" section enumerates 12 defenses, each tied to a
  bug class caught during this PR's review iterations.

Index fix:

- Add the missing 0049 row for "Multi-Protocol Virtual Servers"
  (Accepted, Architecture, 2026-04-16). The 048→050 jump made ADR-050
  look like it had skipped a number when ADR-049 was just un-indexed.
- Other duplicate ADR numbers on disk (005, 034, 038, 048) and other
  index gaps are pre-existing and tracked separately in #4290.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(transports): swappable MCPIngressMount + nginx-style Rust public proxy (ADR-051)

Replace MCPStreamableHTTPModeDispatcher (closed-set, two transports,
policy baked into dispatch) with MCPIngressMount: a thin ASGI
indirection that holds a registry of named ingresses + a swappable
selector. Adding a new ingress is one register() call; selection
policy is one function.

Three ingresses registered today:

- "python" — the existing MCPRuntimeHeaderTransportWrapper. Always
  registered.
- "rust-internal" — the existing RustMCPRuntimeProxy that forwards
  via httpx to MCP_RUST_LISTEN_HTTP / UDS (trusted-internal listener).
  Registered for boot=shadow/edge.
- "rust-public" — new RustMCPPublicProxyApp that does nginx-style
  reverse-proxy to MCP_RUST_PUBLIC_LISTEN_HTTP. Adds X-Forwarded-*,
  RFC 7239 Forwarded, X-Real-IP. Streams both directions, preserves
  Authorization/Cookie. Registered only for boot=edge.

Selector chooses between rust-internal and rust-public based on the
new settings.mcp_rust_ingress (default "internal" preserves prior
behavior bit-for-bit). Selector preserves all existing safety
invariants — _should_mount_public_rust_transport() is consulted first,
shadow override / boot=full / boot=off behave identically to before,
the runtime-mode override admin API requires no changes.

Test results: 1839 passing across the touched modules. Lint clean.

ADR-051 documents the design decision, alternatives considered, and
migration notes from MCPStreamableHTTPModeDispatcher. Index updated.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transports): close XFF spoof + harden Rust public proxy ingress

Response to review of 19677706b (MCPIngressMount + nginx-style Rust
public proxy). Closes a security issue and several diagnostic gaps in
the public proxy and ingress selector.

Security fix — RustMCPPublicProxyApp:

- _build_forwarded_headers now drops client-supplied Forwarded /
  X-Forwarded-* / X-Real-IP from the inbound iteration before
  re-deriving them from request.client. The prior code appended to
  the client-supplied X-Forwarded-For, letting any caller spoof their
  own source IP to Rust's /_internal/mcp/authenticate callback in the
  no-nginx-in-front deployment this proxy targets. New
  _FORWARDED_CHAIN_HEADERS frozenset enumerates the strip set.
- _format_forwarded_for emits RFC 7239 §6.3-conformant `for=unknown`
  (no synthetic `:0`) for absent clients, brackets IPv6 addresses
  exactly once, and tolerates already-bracketed input.

Selector hardening — _select_mcp_ingress / config:

- mcp_rust_ingress is now Literal["internal", "public"] so Pydantic
  rejects typos at boot.
- Selector only returns "rust-public" when boot_mcp_runtime_mode() ==
  "edge" — the public listener isn't bound on shadow boot, so a stale
  edge override + mcp_rust_ingress=public combination would otherwise
  return an unregistered name and silently fall back to Python at
  debug-only log severity.
- _build_mcp_transport_app emits a logger.error (not warning) when
  mcp_rust_ingress=public is set on shadow boot, so the misconfig
  survives the project-default LOG_LEVEL=ERROR.

Error handling — RustMCPPublicProxyApp:

- __call__ now distinguishes asyncio.CancelledError (re-raised),
  httpx.HTTPError (logged with type name + 502), and any other
  exception (logger.exception + 500), so a non-HTTPError no longer
  bypasses the handler and produces a silent Starlette 500.
- _body_iter logs CancelledError at debug (common for SSE client
  disconnect) and other exceptions at error (rare upstream death),
  before re-raising. The finally still calls aclose to return the
  upstream connection to the pool.
- Auth-relevant or server-error upstream statuses (401/403/5xx) now
  log a warning with method+path+status so a credential-rotation flood
  is visible without raising the global log level.

MCPIngressMount diagnostics:

- dispatch's selector-miss paths (fallback used, 503-on-no-fallback)
  now log warning with the unknown name AND self.names(), instead of
  debug-only on the fallback path with no log on the 503 path. A
  misrouted ingress should never be silent.

Tests:

- New tests/unit/mcpgateway/transports/test_rust_mcp_public_proxy.py
  (24 tests). Covers _format_forwarded_for parametrized over IPv4
  with/without port, IPv6, already-bracketed IPv6, None/empty host;
  _build_forwarded_headers spoof rejection + hop-by-hop strip + auth
  preservation + unknown-client emission; proxy ASGI behavior across
  status passthrough, 502 on httpx.HTTPError, 500 on unexpected,
  parametrized warning on (401, 403, 500, 503), non-HTTP scope → 404,
  CancelledError re-raise on send and mid-stream debug-branch, lazy
  client lifecycle, multi-chunk SSE streaming, and aclose-on-exit.
- Two new tests in test_mcp_ingress_mount.py pin the warning-level
  log severity on selector-miss + 503 paths.
- Two new tests in test_main_extended.py pin the boot-time error log
  for the shadow + mcp_rust_ingress=public misconfig and the selector
  downgrade to rust-internal under the same condition.

Doc rot fixes:

- mcp_ingress_mount.py module docstring uses the full ADR filename
  (was an ellipsis).
- version.py docstring references MCPIngressMount instead of the
  removed MCPStreamableHTTPModeDispatcher.
- ADR-051 migration notes use test-group descriptors instead of
  pinned line numbers / test names.

Test results: 652 passing across the touched modules. Lint clean
(black, ruff, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(quality): fix pylint W0706/W2301/R0401 and close coverage gaps

PR review feedback:

Pylint findings:

- W0706 (try-except-raise) at runtime_state.py:933 and
  rust_mcp_public_proxy.py:237: removed the redundant
  ``except asyncio.CancelledError: raise`` blocks. CancelledError is
  BaseException in Python 3.8+, so the surrounding ``except Exception``
  / ``except httpx.HTTPError`` arms never caught it; the explicit
  re-raise was no-op and made pylint complain. Behavior is unchanged
  (asyncio still cancels both coroutines naturally), and the intent is
  preserved as a comment.
- W2301 (unnecessary-ellipsis) at mcp_ingress_mount.py:37: dropped the
  ``...`` from the ASGIApp Protocol's ``__call__`` body — the
  docstring already counts as a body for Protocol methods.
- R0401 (cyclic-import) on the runtime_state ↔ version pair: added
  per-line ``# pylint: disable=cyclic-import`` to the two lazy imports
  in runtime_state.py. Pylint follows function-body imports for cycle
  detection; the lazy imports break the runtime cycle but pylint still
  flags the static graph. The full extract (move MoveCompatibility +
  coercers to a shared module) is bigger refactor than this PR
  warrants.

Coverage gaps closed (per-file delta):

- routers/runtime_admin_router.py 97.3% → 100%. Added three tests for
  ``_warn_if_behind_reverse_proxy``: header detection emits warning
  with header names, no warning when no forwarded headers, defensive
  early return when ``request.headers`` is None.
- transports/mcp_ingress_mount.py 94.7% → 100%. Added test for
  ``set_fallback`` covering swap mid-life and ``None`` restoring
  503-on-miss behavior.
- transports/rust_mcp_public_proxy.py 96.5% → 100%. Added test for
  ``_body_iter`` taking the ``except Exception`` arm on a non-cancel
  mid-stream error: asserts ``logger.exception`` fires with traceback.
- runtime_state.py 89.9% → 99%. Added 9 tests covering: defensive
  ValueError branches in 4 accessor methods (parametrized), idempotent
  ``coordinator.start()``, ``_reconcile_from_hint`` no-redis and
  malformed-mode paths, ``_cleanup_pubsub`` timeout / exception /
  fallback-to-close paths, listen-loop bytes-payload decode + idle
  recovery + malformed-payload + empty-data + uncoerceable-fields
  branches.
- version.py: targeted user-noted gaps (379, 434, 509, 570, 623) all
  closed. Added tests for last_change population in
  _mcp_runtime_status_payload / _a2a_runtime_status_payload, A2A
  shadow override forcing _should_delegate_a2a_to_rust False, and
  the boot_mcp_transport_mount public wrapper. Line 509
  (NO_DISPATCHER fallthrough — _coerce_runtime rejects unknown kinds
  upstream) marked ``# pragma: no cover`` as a defensive default.

Other defensive defaults marked ``# pragma: no cover``:

- runtime_admin_router.py:99 (OK fallthrough — caller already returned
  for OK upstream).
- runtime_state.py:164, 171 in ``_move_compat_to_reconcile_status``
  (OK case shouldn't reach here per docstring; final return is a
  defensive default for future MoveCompatibility variants).

Test results: 1272 passing across the touched modules. Lint clean
(black, ruff, pylint 10.00/10, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* build(container): consolidate on Containerfile.lite; deprecate others (#4297)

Closes #2613.

The top-level `Containerfile` duplicated the .lite build with weaker layer
caching and shipped build tooling in the runtime image. `Containerfile.scratch`
had no frontend-builder stage (admin UI bundle absent), failed under QEMU
(amd64-only), and had no consumer in CI, docs, or the Makefile. Both are
removed; `Containerfile.lite` is now the only canonical gateway image.

Apply the same layer-consolidation and caching pass across `Containerfile.lite`
and the per-component Containerfiles:

- Merge adjacent RUN steps (microdnf + ln + useradd; profile.d setup + chmod;
  Rust MCP + A2A cargo builds; ...) to reduce layer churn.
- Reorder COPYs from stable to volatile so frontend/Rust-binary changes no
  longer invalidate the heavy venv-install layer.
- Use COPY --chown=1001:0 in the UBI nodejs frontend-builder stage — fixes
  EACCES on package-lock.json under the non-root UBI default user — and
  COPY --chmod=0755 on run scripts to drop follow-up chmod layers.
- Replace \`npm install --frozen-lockfile\` (a yarn/pnpm flag npm silently
  ignores) with \`npm ci\`.
- Silence pre-existing hadolint DL3013/DL3041 findings with targeted ignore
  comments consistent with the file-header policy.

Update every touchpoint of the deprecated images: Makefile podman/docker[-dev]
targets, fly.toml, docker-compose commented migration examples, the
docker-scan workflow matrix and trigger paths, .travis.yml,
.pre-commit-config.yaml, .bumpversion.cfg, MANIFEST.in, and user-facing docs
(packaging, release-management, compose, openshift, container, fly-io, argocd
tutorial, MULTIPLATFORM, roadmap). Update the docker-scan unit test to match
the trimmed workflow. Incidentally fix the stale \$(CONTAINERFILE) typo in
the snyk-iac Makefile target.

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat: add HCS-14 UAID support for A2A agents (#4125)

* add HCS-14 UAID support for A2A agents

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* migration

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* lint error fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test case fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* pyporject.toml

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test coverage

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat: add base58 dependency and UAID routing config
  - Add base58>=2.1.1 dependency for UAID generation support
  - Add uaid_allowed_domains config setting for cross-gateway routing security
  - Document UAID_ALLOWED_DOMAINS in .env.example
  Related to #3956

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* a2a_agent_id column size change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat: add UAID support to Admin UI
  - Add UAID generation checkbox to create/edit A2A agent forms
  - Add UAID registry and protocol configuration fields
  - Display UAID vs UUID badge in agents table
  - Show UAID metadata section in agent view modal
  - Add JavaScript toggle logic for UAID fields
  - Update form population for editing UAID agents
  - Rebuild UI bundle with UAID changes
  Completes HCS-14 UAID feature with full UI support.
  Related to #3956

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid ui change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* revert id column length

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid in admin ui create form

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* edit form UAID

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* length change

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* Add security documentation for UAID-based cross-gateway routing feature

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* documentation

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test case

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test: Add coverage for UAID cross-gateway routing edge cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* alembic head

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test coverage

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* base58 and its deps resolved

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security: fix SSRF and domain allowlist bypass in UAID cross-gateway routing

Fixes two critical security vulnerabilities in UAID-based cross-gateway routing:

1. Domain Allowlist Bypass (CVE-level)
   - Previous: endpoint.endswith(d) allowed "evilexample.com" to bypass "example.com" allowlist
   - Fixed: Require exact match OR proper subdomain prefix (.example.com)
   - Now correctly blocks: "evilexample.com", "notexample.com"
   - Still allows: "gateway.example.com", "example.com", "sub.gateway.example.com"

2. SSRF Injection via nativeId (CVE-level)
   - Previous: nativeId field directly interpolated into URL without validation
   - Attack vectors blocked:
     * Protocol injection: file:///etc/passwd, gopher://internal
     * User-info bypass: evil@127.0.0.1 (routes to internal IP)
     * Path injection: gateway.com/admin (access internal endpoints)
   - Legitimate ports still allowed: gateway.example.com:8443

Security validations added before URL construction:
- Reject protocol prefixes (://)
- Reject @ character (user-info bypass)
- Reject path components (/)
- Validate hostname format via urlparse
- Extract domain from endpoint:port for allowlist checking

Test coverage (8 new tests):
- test_invoke_remote_agent_domain_allowlist_bypass_attack
- test_invoke_remote_agent_domain_allowlist_subdomain_valid
- test_invoke_remote_agent_domain_allowlist_exact_match
- test_invoke_remote_agent_ssrf_protocol_injection
- test_invoke_remote_agent_ssrf_userinfo_bypass
- test_invoke_remote_agent_ssrf_path_injection
- test_invoke_remote_agent_ssrf_internal_ip (documents behavior)
- test_invoke_remote_agent_port_allowed

Related: #3956

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* style: apply black and isort formatting to schemas and UAID tests

- black: Fix line length in PluginPolicyItem config field description
- isort: Move First-Party imports to correct section in test files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* uaid split

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* ruff

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* test cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* security test cases

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* varibale correct

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* ruff

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* rebase fix

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>

* feat(a2a): A2A-1.0 UAID path in Rust sidecar + federation-loop guard

Splits UAID cross-gateway routing between the two runtimes by intent:
the Python gateway keeps the ContextForge-to-ContextForge federation
shape (target `/a2a/{uaid}/invoke` on a peer CF gateway), and the Rust
a2a_runtime gets a first-class A2A-1.0-compliant path that POSTs the
original JSON-RPC 2.0 body directly to the agent URL encoded in the
UAID's `nativeId`. The two paths are intentionally not reverse-compatible.

Key pieces:

- `crates/a2a_runtime/src/uaid.rs` (new): HCS-14 parser with SSRF
  rejection (user-info, path injection, unsupported scheme),
  case-insensitive domain allowlist (exact or `.suffix`), DoS length
  cap, sealed `RoutingTarget` with private fields and a `Proto` enum
  (`FromStr` + exhaustiveness test), stable `UaidError::code()` for
  structured logs.
- `crates/a2a_runtime/src/server.rs`: UAID dispatch in
  `handle_a2a_invoke` with local-resolve-first and cross-gateway
  fall-through; SSE proxy (`handle_uaid_cross_gateway_streaming`)
  with Last-Event-ID replay and a bounded JSON fallback that enforces
  `max_response_body_bytes` via a streaming `read_bounded_body` helper
  (per-chunk + cumulative cap); startup validation and rate-limited
  warning for an empty allowlist; explicit one-time security warning
  for the unforwarded-auth tradeoff.
- `mcpgateway/main.py` + `mcpgateway/services/a2a_service.py`: kind-
  dispatch resolve (name/id/uaid) with 403 for visibility-denial so
  the sidecar can distinguish hidden-from-absent; URL-encoded UAID in
  the CF-federation target path; dropped redundant `agent_id` body
  field; structured error logging without leaking 5xx internals.

Federation-loop prevention covers both A->B->A and self-referential
`endpoint_url` loops:

- `X-Contextforge-UAID-Hop: N` counter replaces the earlier binary
  marker. Every outbound (cross-gateway, local-agent, and the
  backward-compat `POST /invoke`) stamps `N+1`; every inbound rejects
  at `uaid_max_federation_hops` (default 5).
- Strict parser with byte-identical rules in both runtimes: pure
  ASCII digits only (no Unicode digits), no whitespace on single
  values, saturating at `2**31 - 1`; RFC 7230 section 3.2.2 coalesced
  form `"0, 10"` parses each element with OWS trim and takes the max
  so smuggled low values can't mask a real high value.
- Max-of-variants defense against header-case smuggling: `HashMap`
  iteration order can't be used to select a low value paired with a
  high one, and axum's multi-valued `HeaderMap` is drained via
  `get_all` + max.
- Named-agent paths translate resolve's 403 to 404 at the axum edge
  so private-agent existence never leaks through the status code or
  the error text (`map_agent_resolve_err` helper).

Config additions:

- Python `uaid_allowed_domains` (list), `uaid_max_length` (2048),
  `uaid_max_federation_hops` (default 5).
- Rust `A2A_RUST_UAID_ALLOWED_DOMAINS`, `A2A_RUST_UAID_MAX_LENGTH`,
  `A2A_RUST_UAID_MAX_FEDERATION_HOPS` (same defaults).

Tests:

- Python unit/integration: kind-dispatch resolve (uaid/id/name fall-
  back), parse_hop_count parametric cases including Unicode digits,
  whitespace, overflow, coalesced RFC 7230 form and smuggling, local-
  agent hop stamping on `_execute_agent_request`, 3-hop chain locking
  in the stamp-N+1 vs reject-at-max contract, TestClient-level
  `/a2a/{agent_name}/invoke` header plumbing.
- Rust integration: 12+ UAID dispatch tests (local hit, cross-gateway
  forward, streaming SSE proxy, body-size cap on chunked responses,
  per-chunk cap, hop-limit at max for UAID and plain names,
  multi-variant smuggling, coalesced header, 403->404 on named paths,
  case-insensitive `/invoke` hop guard).
- Rust uaid.rs: 16 unit tests covering parse/validate/SSRF/allowlist
  round-trip plus `Proto` exhaustiveness and `UaidError::code()`
  stability contracts.

Closes #3956

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Jonathan Springer <jps@s390x.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(#4205): isolate upstream MCP sessions per downstream session (#4299)

* feat(services): scaffold UpstreamSessionRegistry for 1:1 upstream binding (#4205)

Introduces UpstreamSessionRegistry, which maps
(downstream_session_id, gateway_id) to a single upstream MCP ClientSession.
Replaces the identity-keyed MCPSessionPool's sharing semantics: two
downstream MCP sessions — even carrying the same user identity — can no
longer receive the same pooled upstream session. This is the core
isolation change #4205's reproducer needs.

Design:
  - 1:1 per (downstream_session_id, gateway_id). Never shared.
  - Connection reuse preserved WITHIN one downstream session: a client
    making many tool calls against gateway G through session S still
    hits the upstream's initialize exactly once.
  - Health probe on reuse after idle_validation_seconds (60s default),
    chain is ping → list_tools → list_prompts → list_resources → skip.
    Failed probe recreates the upstream session.
  - Transport + ClientSession live inside a dedicated asyncio.Task whose
    anyio cancel scope is bound to that task, not to the request handler
    (#3737). Shutdown is signal-driven via an asyncio.Event; cancellation
    only as a last resort with a timeout.
  - No configurable settings. The tuning surface of the old pool
    (max_per_key, TTL, etc.) collapses under the 1:1 constraint.
  - Purely in-process. Multi-worker stickiness for a downstream session
    is the session-affinity layer's concern (to be extracted next) —
    each worker's registry only sees requests affinity has already
    routed to it.

API surface:
  - acquire() async context manager keyed by downstream_session_id,
    gateway_id, url, headers, transport_type. Rejects empty ids.
  - evict_session(downstream_session_id) for DELETE /mcp paths.
  - evict_gateway(gateway_id) for gateway rotation/removal.
  - close_all() for app shutdown.
  - snapshot() -> RegistrySnapshot for /admin and logs.
  - Module-level init/get/shutdown singleton accessors matching the
    shape of get_mcp_session_pool() so call-sites migrate cleanly.

Tests (16, all passing):
  isolation across downstream sessions (the #4205 invariant), reuse
  within a session, distinct upstreams per gateway for one session,
  concurrent acquires collapse to one create via the per-key lock,
  idle probe + reuse, failed probe + recreate, evict_session /
  evict_gateway / close_all teardown, dead owner-task detection,
  gateway-internal header stripping, and the singleton accessors.
  A FakeClientSession + fake SessionFactory keep the tests hermetic.

Not yet wired: nothing in the codebase calls the registry yet. The
follow-up commits will extract session-affinity to its own module,
wire startup/shutdown + DELETE eviction, migrate tool/prompt/resource
services, refactor gateway health checks, delete MCPSessionPool, and
remove the associated feature flags.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(services): wire UpstreamSessionRegistry into app lifecycle (#4205)

Adds startup init, shutdown drain, and DELETE-triggered eviction for
the registry introduced in the previous commit. All additive; the old
MCPSessionPool still runs alongside for now.

main.py:
  - init_upstream_session_registry() at startup, unconditionally (no
    feature flag — the registry is always on).
  - shutdown_upstream_session_registry() in the teardown block,
    ordered between pool shutdown and SharedHttpClient shutdown to
    ensure upstream sessions close before their HTTP transports go.

cache/session_registry.py (downstream session registry, distinct from
the upstream one this PR introduces):
  - remove_session() now calls get_upstream_session_registry().evict_session(id)
    as its last step. Fires on every path that drops a downstream session:
    explicit DELETE /mcp, internal /_internal/mcp/session DELETE, SSE
    disconnect housekeeping, database-backed session expiry. Wrapped so
    a missing singleton (tests, early shutdown) or an eviction exception
    never masks downstream teardown.

Tests (5 new, all passing):
  remove_session → evict_session forwarding; remove_session tolerating
  an uninitialized singleton; remove_session surviving an evict_session
  that raises; shutdown close_all() call + singleton clear; re-init
  after shutdown returns a fresh instance. Existing session_registry
  coverage tests still green (72 tests, no regressions).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(tool_service): route MCP tool calls through UpstreamSessionRegistry (#4205)

Both MCP call-sites in tool_service.invoke_tool (SSE at ~L5048, StreamableHTTP
at ~L5230) now acquire their upstream ClientSession from the registry when a
downstream Mcp-Session-Id is in scope. This is the first visible behavioural
change of the #4205 fix: two downstream MCP sessions served by the same user
now build SEPARATE upstream sessions, so stateful upstream servers (counter,
etc.) no longer leak state between downstream clients.

Changes:
  - Replace the conditional pool path with an unconditional registry path,
    gated on the presence of a downstream Mcp-Session-Id (read from the
    transport's request_headers_var via a new _downstream_session_id_from_request
    helper) AND a gateway_id AND not tracing_active.
  - Drop the `settings.mcp_session_pool_enabled` check at the call-sites.
    The registry is always on; its applicability is determined by whether a
    downstream session id is in scope.
  - Keep the per-call fallback path for callers without a downstream
    session id: admin UI test-invoke, internal /rpc, and anything that
    drives the tool_service outside the streamable-http transport.
  - Preserve the tracing trade-off: when tracing_active, skip the registry
    to allow per-request traceparent/X-Correlation-ID injection.
  - Remove the now-unused `get_mcp_session_pool, TransportType` import from
    mcp_session_pool; TransportType is now imported from the registry module.
  - _downstream_session_id_from_request uses a lazy import of
    streamablehttp_transport.request_headers_var to avoid a circular
    dependency at module load time.

Tests:
  - NEW test_invoke_tool_mcp_two_downstream_sessions_hit_registry_with_distinct_ids:
    the direct-consequence #4205 test — two invocations with different
    downstream session ids must produce two acquire() calls with distinct
    (session_id, gateway_id) keys, same gateway, different sessions.
  - Rewrote test_invoke_tool_mcp_pooled_path_does_not_inject_trace_headers
    as the equivalent registry-path test (same invariant: reused upstream
    transports must not receive per-request trace headers).
  - Rewrote 4 pool-hit tests in test_tool_service_coverage.py to use the
    registry API (and set request_headers_var with a session id).
  - 870 related tests pass; no regressions.

This migration leaves the old pool in place — it simply isn't called from
tool_service anymore. Prompt/resource/gateway call-sites still point at the
pool and will migrate in the next commits.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(prompts, resources): route MCP calls through UpstreamSessionRegistry (#4205)

Mirrors the tool_service migration: prompt_service and resource_service acquire
their upstream MCP ClientSession from the registry when a downstream Mcp-Session-Id
is in scope, falling back to per-call sessions otherwise. Same 1:1 isolation,
same connection reuse within a session, same trace-header trade-off.

Changes:
  - prompt_service._fetch_prompt_from_gateway: replace pool path with registry
    path; drop the `settings.mcp_session_pool_enabled` gate; drop the now-unused
    `pool_user_identity` local.
  - resource_service.invoke_resource SSE + StreamableHTTP helpers: same
    rewrite. Also delete the `pool_user_identity` normalization block at
    line ~1708 (no longer referenced).
  - upstream_session_registry: add `downstream_session_id_from_request_context()`
    so the three services share one implementation. tool_service now aliases
    the shared helper rather than carrying its own copy.
  - TransportType is imported from upstream_session_registry in all three
    services; the pool's copy becomes unused and disappears in the
    hollow-and-rename step.

Tests:
  - Rewrote 4 pool-hit tests in test_resource_service.py as registry-path
    tests (set request_headers_var with a downstream session id, patch
    get_upstream_session_registry). Renamed for accuracy:
    test_sse_session_pool_used_and_signature_validated →
      test_sse_registry_used_and_signature_validated
    test_invoke_resource_streamablehttp_uses_session_pool_when_available →
      test_invoke_resource_streamablehttp_uses_registry_when_available
    The two "pool not initialized falls back" tests now simulate an
    uninitialized registry via RuntimeError from get_upstream_session_registry.
  - 1173 service-layer tests pass; no regressions.

Two holdouts still touch the pool: gateway_service's health check (task #19)
and the pool itself (task #23 deletes its upstream-session code and renames
the file to session_affinity.py).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* refactor(gateway_service): drop pool from health checks (#4205)

Gateway health checks are system operations — no downstream MCP session id
is in scope — so they can't key the UpstreamSessionRegistry. They also
don't benefit meaningfully from connection reuse: each probe is a one-shot
initialize round-trip to detect reachability. Use a fresh per-call session
unconditionally.

Changes:
  - _check_single_gateway_health (streamablehttp branch): replaces the
    pool-or-fallback block with a single streamablehttp_client +
    ClientSession per probe.
  - Drop the `mcp_session_pool_explicit_health_rpc` feature flag usage
    (the setting itself disappears in the config cleanup commit). The
    initialize() round-trip is the probe; no optional list_tools() needed.
  - Imports: drop `get_mcp_session_pool, TransportType` from the pool
    import; keep `register_gateway_capabilities_for_notifications`
    (still used by three other call-sites in gateway_service). Also drop
    the now-unused `anyio` import (was used only for the pool branch's
    fail_after on list_tools).

Tests:
  - Rewrote test_streamablehttp_pool_not_initialized_falls_back_to_per_call_session
    as test_streamablehttp_health_uses_per_call_session (since per-call is
    now unconditional, that's the whole behavior the test pins).
  - Deleted test_streamablehttp_pool_used_and_explicit_health_rpc_calls_list_tools
    (exercised a code path that no longer exists).
  - Deleted tests/unit/mcpgateway/services/test_gateway_explicit_health_rpc.py
    entirely — it only tested the MCP_SESSION_POOL_EXPLICIT_HEALTH_RPC
    feature flag's on/off branches.
  - 168 gateway_service tests pass; no regressions.
Signed-off-by: Jonathan Springer <jps@s390x.com>

* refact…
brian-hussey added a commit that referenced this pull request May 20, 2026
…4668)

* Oauth gateway Enhancement (#4672)

* Oauth gateway Enhancement

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* test cleanup

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* fix tests

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* fix coverage

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* added runtime validation

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* fix tests/add runtime validation

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

---------

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(tests): resolve UI test suite failures across auth, CSRF, and password policy (#4701)

* fix(tests): resolve UI test suite failures across auth, CSRF, and password policy

Fix multiple root causes of Playwright UI test failures:

Password Policy Compliance:
- Update all test passwords to 22+ character random strings meeting policy
- Fixes "Password must not be based on your username" validation error
- Prevents redirect loop caused by failed password-change flow

Admin Auth & Redirects:
- Refactor AdminAuthMiddleware to use shared validate_token_user
- Fix admin_login_page to check admin access before redirecting to /admin
- Resolve ERR_TOO_MANY_REDIRECTS on logged-in user login-page access

CSRF Protection:
- Add CSRF token to getAuthHeaders and fetchWithTimeout
- Add /rpc to csrf_exempt_paths for JSON-RPC protocol requests
- Fix viewer cookie RPC tools call 403

UI JavaScript Fixes:
- Guard window.Admin assignments with typeof checks for DOMContentLoaded
- Add openCreateTeamModal/closeCreateTeamModal to window.Admin
- Fix toggleDropdownImportMethod ReferenceError

Infrastructure:
- Apply forwarded_host map to nginx configs and Helm chart
- Preserve X-Forwarded-Host header through proxy chain

Data & Permissions:
- Converge permissions on existing system roles in bootstrap_db
- Ensure viewer role gets servers.use permission

Files changed:
- .env.example
- mcpgateway/admin.py, main.py, auth.py, config.py, bootstrap_db.py
- mcpgateway/middleware/rbac.py
- mcpgateway/admin_ui/*.js
- mcpgateway/templates/*.html
- infra/nginx/*.conf
- charts/mcp-stack/templates/configmap-nginx-proxy.yaml
- tests/playwright/conftest.py and test files
- tests/js/admin-async.test.js

Signed-off-by: Jonathan Springer <jps@s390x.com>

* test: add unit tests for diff coverage gaps

Add tests covering missing lines from the UI test fixes diff:

Admin login RBAC (admin.py):
- Non-admin with valid token + RBAC admin permission → redirect to /admin
- Non-admin with valid token + no RBAC permission → render login page
- Token revocation success and 404 paths

Auth validation (auth.py):
- validate_token_user with empty token raises TokenValidationError
- validate_token_user with generic exception raises TokenValidationError

Bootstrap DB (bootstrap_db.py):
- System role permission convergence when schema changes

AdminAuthMiddleware (main.py):
- _auth_error_param mappings for disabled/expired/idle timeout
- Proxy auth platform admin bootstrap
- Proxy auth user not found → 401
- Proxy auth disabled user → 403
- HTTPException and generic exception handling in dispatch

Security headers (security_headers.py):
- root_path stripping for CSP skip check on docs endpoints

Signed-off-by: Jonathan Springer <jps@s390x.com>

* test: add missing import for admin_reflect_grpc_service

Fix NameError in three grpc tests by importing admin_reflect_grpc_service
from mcpgateway.admin in the test file.

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* [Chore] Changes for release of 1.0.1 (#4720)

* Update all python and rust dependencies following new cpex-plugins build

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update pii filter version

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Updates for release 1.0.1

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct Linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update license headers

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct bandit findings

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update Manifest and base target version

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Fix idepotency in migration (#4735)

Signed-off-by: cafalchio <mcafalchio@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Cleaning alembic issue with v1.0.0 alembic_version

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update alembic 8a16a77260f0 to do idempotency checks

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: upgrade path from 0.9.0 - unnamed FK handling, temp table cleanup, psycopg driver detection (#4738)

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: correct middleware execution order for password change enforcement (#4742)

Fixes #4736

This change hardens the login process by ensuring the password change
enforcement middleware executes after authentication context is established.

Changes:
- Swapped middleware registration order in main.py (lines 3091-3134)
- PasswordChangeEnforcementMiddleware now added first (runs second)
- AuthContextMiddleware now added second (runs first)
- Added comprehensive security edge case tests (18 new tests)
- All 30 tests passing (18 new + 12 existing)

The middleware execution order is critical because Starlette/FastAPI
processes middleware in reverse order of addition. This ensures
request.state.user is populated before password enforcement checks.

Test coverage includes:
- All HTTP methods enforcement
- Missing/None attribute handling
- Path traversal prevention
- Auth method variations
- State isolation between requests
- Feature flag integrity

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update manual testing instructions for Docker reachability and registration process (#4739)

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Add detect secrets baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: guard 356a2d4eed6f downgrade when prompts/resources tables are absent; disable regex_transformer and retry_backoff plugins (#4748)

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(ci): disable forced password-change on bootstrap in Playwright smoke tests

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: update assertions for blocklist activity checks in idle timeout tests (#4750)

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: cafalchio <mcafalchio@gmail.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Co-authored-by: __cafa__ <47299428+cafalchio@users.noreply.github.com>
Co-authored-by: madhu-mohan-jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Vishu <vishu.bhatnagar@ibm.com>
Co-authored-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct issue #4756 - linting issues following release (#4759)

* Correct linting issues with go directories

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update release document to clarify what needs to be kept in sync.

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update Dockerfiles and documentation for details

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(serve): use project .venv exclusively in run-gunicorn.sh (#4767)

* fix(run-gunicorn): streamline virtual environment activation process

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* format: fixup

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

---------

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update secrets baseline (#4614)

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Updating Codeowners to include Akshay and Jitesh as additional code owners (#4769)

Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Sql sanitizer nested strings (#4730)

* basic logging for sql sanitizer

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* workflow

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* workflow

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* add config log with payload

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* fix nested strings

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* test

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* fix param

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* fix workflow

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

* add simple test case

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>

---------

Signed-off-by: Dmitry Zakharov <zakharov@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Merge commit from fork

* fix: Oauth Redirects

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* fix test cases

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* docs: added SSRF redirect protection migration guide

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* updated doc

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

* updated doc

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

---------

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Moved DB creation and migrations fully to alembic (#4690)

* Fixed migration history
   - Migrations are now linear

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Moved DB creation responsability to alembic
    - Removed db.create_all()
    - Fix alembic initial migration to not depend on create_all

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* fix revises reference to 3 versions

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Lint and secrets fix

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* updated secrets

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Added initial tables on the base migration

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* fixed lint

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Added test for exception coverage

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Removed create_all from bootstrap

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* check if table exist before first migration

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* check if table exist before first migration

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Remove doctest reference to nonexistent atribute

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Rebased and fixed wrong table check

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Fixed a2a_agents_and_metrics not being created

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Fixed dead code and added indepotency to migration

Signed-off-by: cafalchio <mcafalchio@gmail.com>

* Correct pre-commit errors introduced with rebase

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: cafalchio <mcafalchio@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: register team modal actions with Admin namespace (#4728)

Fixes #4727

Keep create-team modal registrations guarded, avoid duplicate team handler registrations already present on main, and migrate remaining team modal callbacks to delegated data-action handlers.

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(visibility): always OR global-public condition when team_id filter is applied (#4732) (#4773)

Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* format: fixup autoflake, isort, black, ruff &  pre-commit (#4798)

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(tools): Add CSP nonce support to OAuth callback page (#4776)

* fix: Add CSP nonce support to OAuth callback page

The OAuth callback success page contained inline JavaScript without CSP
nonce, causing browsers to block script execution and preventing the
'Fetch Tools from MCP Server' button from working.

Changes:
- Add CSP nonce to inline script tag in OAuth callback page
- Remove inline onclick handler, replace with addEventListener
- Wrap script in IIFE for proper scoping
- Add comprehensive integration tests for CSP compliance

This fixes the issue where OAuth-configured MCP servers (ClickUp, Gamma,
Granola, Notion) could not fetch tools after successful authorization.

Closes #4424 regression

Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>

* test: Move CSP compliance tests to OAuth router test suite

Address PR review feedback by moving CSP nonce tests to the correct
location alongside existing OAuth router tests. The new tests properly
exercise the actual /oauth/callback endpoint (not /health) and verify:

- CSP nonce is present in the OAuth callback success page script tag
- No inline onclick handlers (CSP violation)
- addEventListener pattern is used for CSP compliance
- IIFE wrapper for proper scoping
- Graceful handling of missing CSP nonce
- Error pages don't include inline scripts
- Nonce uniqueness per request

This addresses the merge blocker identified in PR review where the
original tests in tests/security/ did not exercise the changed endpoint.

Changes:
- Delete tests/security/test_oauth_callback_csp_compliance.py (wrong location)
- Add TestOAuthCallbackCSPCompliance class to test_oauth_router.py
- 4 comprehensive tests that directly call oauth_callback() endpoint
- All 107 OAuth router tests pass including new CSP tests

Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>

---------

Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
Co-authored-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat(observability): add baggage attribute mapping for span customization (#4705)

* feat(observability): add baggage attribute mapping for span customization

Implements Option A semantics for SpanAttributeCustomizer plugin:
- Extract attribute_mapping from plugin config at startup
- Add BaggageAttributeSpanProcessor to duplicate baggage.* attributes
- Initialize telemetry after plugin manager to access config
- Add comprehensive test coverage for mapping extraction and processor

This allows operators to remap baggage-derived span attributes like
baggage.tenant.id to custom keys like tenant.id for backend compatibility.

Fixes #4700

Signed-off-by: Bob Shell <bob@example.com>
Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* test: add coverage for baggage attribute mapping edge cases

- Add test for extract_span_attribute_mapping with None factory
- Add test for BaggageAttributeSpanProcessor with empty mapping
- Add test for BaggageAttributeSpanProcessor with None span
- Add test for BaggageAttributeSpanProcessor skipping None values
- Add test for BaggageAttributeSpanProcessor on_end returning None

These tests cover lines 416, 468, 478, and 487 in observability.py,
bringing coverage from 92.3% to above the 93% threshold.

Relates-to: #4700
Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* fix: resolve pylint warnings

- Prefix unused 'span' parameter with underscore in observability.py
- Remove redundant SessionLocal import in admin.py

Fixes pylint unused-argument and reimported warnings.

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* feat: add default tenant.id baggage header mapping for testing

- Set OTEL_BAGGAGE_HEADER_MAPPINGS=x-tenant-id:tenant.id in Langfuse compose

- Enables testing of tenant.id propagation to span attributes via baggage

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* feat: reverting test changes

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* docs: add inline comments for telemetry init reordering

Explain why span_attribute_mapping extraction happens before
telemetry initialization - it must run after plugin manager init
to access plugin configuration.

Addresses review comment about missing documentation for
telemetry init reordering logic.

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* Add baggage span attribute policy

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* feat: PR comment added

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* chore: revert formatting changes in Alembic migrations to match main

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

* chore: update .secrets.baseline after rebase

Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>

---------

Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>
Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update transitive dependencies (#4794)

Signed-off-by: Chris PC <chrispc@li-4dc2bf4c-325d-11b2-a85c-b68e8b1fc307.ibm.com>
Co-authored-by: Chris PC <chrispc@li-4dc2bf4c-325d-11b2-a85c-b68e8b1fc307.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(llmchat): add missing LangChain/LangGraph dependencies in Containerfiles (#3922)

* fix(llmchat): add missing LangChain/LangGraph dependencies in Containerfiles

Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>

* Update secrets

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: eliminate migration lock contention on multi-replica startup  (#4784)

* Add migration service to docker-compose and enhance bootstrap_db with alembic head check

- Defined migration service in docker-compose.yml to handle schema migrations.
- Implemented _is_at_alembic_head function in bootstrap_db.py to check if the database is at the current alembic head, optimizing migration process.
- Introduced skip_migration setting in config.py to allow external management of migrations.

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* Add configuration to skip alembic migrations on startup and update tests

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* Enhance bootstrap_db to handle fresh DB migrations when skip_migration is true and update tests accordingly

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* Add tests for _is_at_alembic_head and enhance main function coverage

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* format: fixup

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* Refactor imports in bootstrap_db.py to streamline alembic migration context usage

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* Update configuration to disable migration skipping and adjust related tests for fresh DB handling

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

* Refactor logging for SKIP_MIGRATION handling in main function

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>

---------

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: Compose: harden gateway service (no-new-privileges, cap_drop, read_only, tmpfs) (#4469)

* chore: security hardening for startup logs (#4507)

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore: security hardening for startup logs

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* [chore] Release version 1.0.0 (#4551)

* Update reference from tools_rust to crates in bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update version in root Cargo.toml file and reference in .bumpversion.cfg

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update secrets

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update base image versions

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Corrected version of UUI dependency

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Linting updates to requirements.txt files

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update front end dependencies

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update npm deps and black finding

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* License updates and linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update to not fail on test setup

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release PII tool invocation steps (#4521)

* docs: fix release PII tool invocation steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: align release PII verification steps

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: fix release docs build links

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: scope PII release check to tool hooks

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* Revert "docs: fix release docs build links"

This reverts commit 88c4b7b3d1c4e462d1e06e329ab4aff8e13eec42.

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: use admin token for plugin status check

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: run discovered PII filter tests

Signed-off-by: lucarlig <luca.carlig@ibm.com>

* docs: remove PII unit test step

Signed-off-by: lucarlig <luca.carlig@ibm.com>

---------

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: fix release docs build links (#4523)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update CHANGELOG with PR 4507 details

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update reference material

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Alembic linearity fix (#4547)

* Alembic linearity fix

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix: handle ix_/idx_ index name mismatch in migration downgrades

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update alembic migration tools

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(alembic): drop orphaned ix_token_revocations_token_expiry on downgrade

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Final Update to CHANGELOG.md

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Address final linting issues

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Update cargo package details for cargo vet check

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Correct test coverage within previously cleaned code audit_trail_service

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Co-authored-by: Luca <luca.carlig@ibm.com>
Co-authored-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Fix/alembic migration safety (#4479)

* fix(alembic): safe downgrade + roundtrip CI validation

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(alembic): add migration_metadata table for hermetic downgrade config snapshots

Introduces a new migration (f9a8b7c6d5e4) that creates a
migration_metadata(revision, key, value, created_at) table. Data
migrations that read settings.* at upgrade time now write a config
snapshot into this table so that downgrade() can use the original
values regardless of the current environment.

ba202ac1665f (migrate_user_roles_to_configurable_defaults) is wired
to this table:
- upgrade(): snapshots default_admin_role, default_user_role,
  default_team_owner_role, default_team_member_role immediately after
  reading them from settings.
- downgrade(): reads the snapshot first; falls back to live settings
  only if the table/rows are absent, logging a warning.

scripts/ci/run_upgrade_validation.sh: replace post-downgrade strict
equality assert with assert_version_present. The migration graph has a
parallel branch (aa1b2c3d4e5f) that diverges below the base-image
head; Alembic correctly leaves that branch tip in alembic_version after
downgrade. assert_version_present verifies the expected version is
present and logs the extra heads rather than failing.

All 6 CI checks pass (SQLite fresh, SQLite upgrade, PostgreSQL fresh,
PostgreSQL upgrade, SQLite roundtrip, PostgreSQL roundtrip).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* chore(make): add migration-test-rollback target

Adds a dedicated `make migration-test-rollback` Makefile target that
runs pytest -k 'reverse or rollback' on both SQLite and PostgreSQL test
files, followed by the full upgrade/downgrade roundtrip harness
(scripts/ci/run_upgrade_validation.sh).

Also updates the upgrade-validate help comment to reflect that it now
validates fresh, upgrade, AND roundtrip (downgrade) paths, since
run_sqlite_roundtrip/run_postgres_roundtrip were added in the previous
commit.

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* test(migration): add cross-DB schema consistency test

Adds tests/migration/test_cross_db_schema_consistency.py, a new pytest
module that boots the target image twice — once with SQLite, once with
PostgreSQL — applies Alembic migrations to head in each case, then
introspects both schemas via SQLAlchemy's Inspector API (exec'd inside
the running container) and asserts:

  test_same_tables_exist      - identical table set (66 tables)
  test_column_names_match     - same column names per table
  test_column_types_match     - equivalent types after canonicalisation
  test_primary_keys_match     - same primary key columns
  test_foreign_keys_match     - same FK relationships

Engine-specific type differences (VARCHAR vs TEXT, TIMESTAMPTZ vs
DATETIME, JSONB vs JSON, BYTEA vs BLOB, etc.) are normalised through a
_TYPE_CANON mapping so the test asserts semantic equivalence, not
string identity.  Unknown type mappings produce a clear failure message
directing the author to extend _TYPE_CANON.

Also adds make migration-test-cross-db, a dedicated Makefile target.

Verified locally: 5/5 tests pass in 22 s against
mcpgateway/mcpgateway:upgrade-ci-test (67 tables on each engine).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* docs: document migration CI coverage decision and config snapshot pattern

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(make): fix tab indentation in migration-test-rollback and migration-test-cross-db targets

Makefile recipe lines were indented with 8 spaces instead of tabs,
causing 'missing separator' parse errors that triggered the Rust CI
workflow (which monitors Makefile changes).

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff errors in new migration files and cross-DB test

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): fix ruff F841 and detect-secrets false positives

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix: address PR review feedback - migration safety hardening

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* fix(lint): remove extra blank lines in db.py (E303)

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>

* Update secrets baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* chore(deps): bump cpex-rate-limiter to 0.0.6 (#4596)

* chore(deps): bump cpex-rate-limiter to 0.0.6 (TLS support release)

Pulls in the TLS-support release published from cpex-plugins#74,
which enables the rate-limiter plugin to connect to managed Redis
endpoints that require TLS (`rediss://` URLs).  Operators with
managed Redis services that mandate in-transit encryption (AWS
ElastiCache with encryption enabled, Redis Cloud, IBM Cloud
Databases for Redis, etc.) can now point the plugin at those
endpoints without tripping the prior `InvalidClientConfig: TLS
feature not enabled` plugin-init failure.

Three coordinated edits:

- ``pyproject.toml:15`` — bump uv ``exclude-newer`` date pin from
  ``2026-04-22T23:59:59Z`` (covered up to and including 0.0.4) to
  ``2026-05-31T23:59:59Z`` so uv sees the 0.0.6 wheel
  (uploaded 2026-05-05T08:13:13).
- ``pyproject.toml:281`` — bump min-version constraint
  ``cpex-rate-limiter>=0.0.4`` -> ``cpex-rate-limiter>=0.0.6`` so
  fresh installs pull the TLS-enabled wheel.
- ``uv.lock`` — regenerated via
  ``uv lock --upgrade-package cpex-rate-limiter`` to pin the
  resolved version + per-platform wheel hashes for 0.0.6.

Local-dev defaults unchanged.  ``docker-compose.yml``'s
``REDIS_URL=redis://redis:6379/0`` continues to work — TLS is
opt-in via a ``rediss://`` URL.

Verified locally: ``uv sync --extra plugins`` installs 0.0.6 cleanly,
``cpex_rate_limiter.rate_limiter.RateLimiterPlugin`` imports without
error, and the plugin-framework loader unit tests stay green.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

* chore(deps): align RateLimiterPlugin version field in plugins/config.yaml

The shipped ``plugins/config.yaml`` carried ``version: "0.0.3"`` for
``RateLimiterPlugin`` long after the published wheel had moved on.
Bumping to ``0.0.6`` to match the version the previous commit pulls
in (the TLS-support release from cpex-plugins#74).

This field is operator-facing metadata mirrored from the plugin's
own ``plugin-manifest.yaml``; it's not a dependency constraint, so
it does not affect resolution.  Updating it keeps the shipped
sample config truthful about which plugin version operators are
loading by default.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

---------

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat: Add populate-tiny to Makefile (#4563)

* feat: Add populate-tiny to Makefile

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

* Update secrets.baseline

Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

---------

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
Co-authored-by: Brian Hussey <brian.hussey@ie.ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* Remove rc3 references (#4567)

Signed-off-by: Gabriel Costa <gabrielcg@proton.me>

Comment: By-passing checks as queue too long, this is low risk change.
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* docs: document cargo-vet release workflow (#4600)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* bugfix/3825-playwright-htmx-dom-detach (#4264)

* fix: update scope['modified_path'] for server_id extraction with root_path (#4270)

Fixes #4266

The /mcp endpoint was returning ALL tools instead of server-scoped tools
when accessed via /servers/{id}/mcp in reverse proxy deployments with
root_path configured. The /sse endpoint worked correctly.

Root cause: MCPPathRewriteMiddleware computed app_path by stripping
root_path prefix (line 3026) but never updated scope['modified_path'].
When streamablehttp_transport.py:2898 reads modified_path to extract
server_id via regex (r'^/servers/(?P<server_id>[^/]+)/mcp'), the regex
fails to match paths like '/prefix/servers/123/mcp', causing server_id
to be None and tools/list to return all tools instead of server-scoped.

Solution: Update scope['modified_path'] = app_path after computing the
app-relative path. This ensures streamablehttp_transport receives the
path without root_path prefix for correct server_id extraction.

Changes:
- Set scope['modified_path'] to app_path after stripping root_path
- Add regression tests verifying modified_path is app-relative
- All existing MCPPathRewriteMiddleware tests pass

Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* fix(transport): return 405 on session-less GET /mcp (#4205) (#4284)

* fix(docker): install wget in a2a-echo-agent image for healthcheck

The compose healthcheck for a2a_echo_agent runs `wget -qO- http://localhost:9100/health`,
but the debian:bookworm-slim runtime stage only installed ca-certificates and tzdata —
so every probe failed with "exec: wget: executable file not found in $PATH" and the
container stayed perpetually unhealthy despite the agent itself listening correctly.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): return 405 on session-less GET /mcp (#4205)

A strict MCP client opens a passive SSE stream via GET right after initialize
to receive server-initiated messages. When ContextForge cannot anchor that
stream to a session (stateful sessions disabled, session-core disabled in
Rust, or no Mcp-Session-Id presented), it previously either committed to an
empty event stream (Rust live-stream relay) or handed off to the SDK which
held the GET open without emitting events. Strict clients then timed out on
the empty stream and reported "Failed to initialize server session".

The MCP Streamable HTTP spec allows the server to return 405 for such GETs;
this change takes that route in both gateways.

Python (mcpgateway/transports/streamablehttp_transport.py):
  - SessionManagerWrapper.handle_streamable_http now returns 405 with
    Allow: POST, DELETE for GET when either settings.use_stateful_sessions
    is false or no Mcp-Session-Id was presented.
  - The check runs after _validate_server_id() and the RBAC gate so bogus
    server IDs still 404 and unauthorized callers still 403, preserving the
    "RBAC before processing" invariant.
  - Branch-specific detail messages distinguish "stateful sessions disabled"
    from "no session id presented" so clients don't retry in a loop.
  - logger.info on rejection with path + flags for ops visibility.

Rust (crates/mcp_runtime/src/lib.rs):
  - forward_transport_request returns 405 for GET when either
    session_core_enabled is false or no session id is presented in header
    or session_id query parameter. Without session_core, Rust cannot anchor
    an SSE stream; without a session id, there is nothing to anchor to.
  - The 405 response carries the same capability headers as every other
    terminal response in the function.
  - tracing::info! on rejection and a RuntimeStats.transport.get_rejected_no_session
    counter so ops can distinguish "no traffic" from "every client rejected".
  - One pre-existing test (server_scoped_transport_wrappers_inject_server_header)
    updated to provide a session id + record so it still exercises the
    server-id header injection path.

Tests:
  - Python: 3 new parametrized tests covering stateless/no-session/pass-through
    and both Mcp-Session-Id header aliases; server-scoped variant confirms
    405 fires only after server-id validation (and that 404 still wins for
    nonexistent servers).
  - Rust: 3 new tests covering 405 without session id, 405 when session_core
    disabled even with header, and live-stream branch still reached when a
    valid session id is presented.
  - Also un-hollows two pre-existing header-parsing tests
    (test_handle_streamable_http_non_tuple_header_skipped,
    test_handle_streamable_http_non_bytes_header_skipped) that would have
    been silently short-circuited by the new 405 path.

Note: This fixes the downstream GET-SSE cascade described in the comment
on #4205. The upstream per-client session-isolation problem from the
original reproducer (shared pooled upstream session leaking state between
downstream clients) is a separate architectural change and will be
addressed in a follow-up.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): address review feedback on GET /mcp 405 path (#4205)

Follow-up to 252e8c228. Addresses review feedback surfaced by the
PR-review agents:

- Rust `runtime_session_id_from_request` now also accepts
  `x-mcp-session-id`, restoring parity with the Python gateway so a
  client isn't accepted by one runtime and 405'd by the other.
- Split the Rust 405 counter: `get_rejected_no_session` stays as the
  total and a new `get_rejected_session_core_disabled` subcounter
  isolates the deployment-config branch, so ops can distinguish client
  misuse from session-core being off.
- Added a Python Prometheus counter (`transport_get_rejected_total`)
  with matching `no_session_id` / `stateful_disabled` outcomes to close
  the observability gap between runtimes.
- Split log levels on both sides: `warn` for the operator-facing
  disabled-session / stateful-disabled branch, `debug` for the
  no-session-id branch (routine strict-client probing).
- Added a regression guard test that GET with RBAC denial returns 403
  (not 405) and emits no `Allow` header.
- Session-affinity `except Exception` in the Python gateway now warns
  instead of silently rendering infrastructure failures as 405s.
- 404 on unknown server no longer allowed to leak an `Allow` header.
- GET OAuth-enabled server test asserts 401 before the 405 path.
- Rust happy-path tests snapshot counters to assert no 405 tick.
- "Why 405 (not 404/400)" rationale added to both comment blocks.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transport): gate Rust 405 inside live-stream branch (#4205)

Integration tests `get_and_delete_mcp_routes_forward_to_internal_transport_bridge`
and `live_stream_core_disabled_falls_back_to_python_transport_get` were
failing in CI: the standalone 405 block in `forward_transport_request`
pre-empted the Python transport-bridge fallback path, even for GETs the
runtime could have cleanly proxied.

The empty-stream cascade the 405 was defending against only arises when
the live-stream relay is entered without a session anchor. Move the 405
guard inside that branch so GETs that don't request SSE (or where
live_stream_core is disabled) fall through to the existing Python-proxy
fallback, which has its own 405 logic. Update the matching unit test to
set `Accept: text/event-stream` so it still reaches the gate.

No change to the counter semantics or Python-side behaviour. Full
workspace test suite (Rust + Python) passes.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(rust): apply clippy 1.95.0 lint fixes in non-PR crates

Rustc stable moved from 1.94.1 to 1.95.0 between the last main Rust CI
run (#3704, 2026-04-16) and PR #4284, enabling two additional lints at
workspace `-D warnings` level. The new lints flag pre-existing code in
crates outside this PR's surface area, blocking Rust clippy CI for every
branch that touches any Rust file.

Apply clippy's exact suggestions:

- `crates/wrapper/src/json_rpc_id_fast.rs`: collapse a `FieldName`
  match arm's nested `if` into a match guard (`collapsible_match`).
- `crates/a2a_runtime/src/event_store.rs`: drop a redundant
  `.into_iter()` in a `.zip()` argument (`useless_conversion`).
- `crates/a2a_runtime/src/server.rs`: collapse two match-arm nested
  `if`s (`get`|`cancel` and `list`) into match guards.

Verified with `cargo clippy --workspace --all-targets -- -D warnings`
and `cargo test --workspace` on rustc 1.95.0. No behaviour change.

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* feat(runtime): runtime-mutable RUST_MCP_MODE + A2A_MODE via admin API (#4296)

* feat(runtime): runtime-mutable MCP and A2A mode via admin API

Add PATCH /admin/runtime/mcp-mode and PATCH /admin/runtime/a2a-mode that
flip the public /mcp ingress and registered-A2A invocation path between
shadow and edge at runtime without a restart. Boot env vars
(RUST_MCP_MODE, RUST_A2A_MODE) still select the initial mode; overrides
are in-memory only. When Redis is configured the override propagates
cluster-wide via pub/sub on contextforge:runtime:mode with a per-runtime
monotonic INCR counter and a 24h hint key per runtime so freshly-started
pods reconcile to the cluster's current desired override on boot.

Off and full boot modes are intentionally not flippable (409): off has
no Rust sidecar to swap to; full keeps Rust as the owner of MCP session/
event-store/resume cores and flipping to shadow would orphan that state.

The reverse-proxy caveat (admin flips don't propagate past an upstream
nginx until the proxy is reconfigured) is documented and tracked
separately in #4278.

Closes #4273

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): preserve session-auth-reuse safety invariant under override

Codex stop-time review caught that `override="edge"` on a shadow-boot
deployment silently bypassed the documented safety invariant for routing
public traffic to Rust: `_should_mount_public_rust_transport` had been
loosened to require only `experimental_rust_mcp_runtime_enabled`, skipping
`experimental_rust_mcp_session_auth_reuse_enabled` (and the analogous
`delegate_enabled` for A2A). A boot=shadow deployment with an admin-issued
edge override would have started routing public MCP to a Rust sidecar
that never opted into session-auth-reuse.

Fix:
- `_should_mount_public_rust_transport` keeps the original invariant
  unchanged. Override="shadow" still forces Python; override="edge" now
  matches the default (honors the invariant). Same shape for
  `_should_delegate_a2a_to_rust`.
- Narrow `_FLIPPABLE_BOOT_MODES` to `{edge}`. Only boot=edge deployments
  have both flags true and can safely flip. boot=shadow PATCHes now
  return 409 with an operator-facing explanation of the invariant.
- Two belt-and-braces unit tests exercise the read side directly (even
  if a stale hint somehow landed override=edge in state, the transport
  function refuses to route to Rust without the safety flags).
- Two router tests cover the new 409 for shadow boot (MCP + A2A).
- Docs updated: architecture doc, ADR 043 addendum, crate README/STATUS,
  .env.example, docker-compose.yml.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): allow shadow override clearance and discard incompatible hints

Codex stop-time review caught a new dead-end introduced by the previous
safety-invariant fix: after restricting _FLIPPABLE_BOOT_MODES to {edge},
a shadow-boot deployment that inherited a stale override=edge via a
Redis hint (left by a prior edge-boot deploy) had no API path to clear
state — every PATCH returned 409. The operator's only recourse was
flushing Redis by hand.

Fix is two-sided:

Router:
- Gate on target mode, not boot mode. mode=shadow accepted from any
  boot that has a dispatcher mounted (shadow or edge). mode=edge still
  requires boot=edge (safety invariant). off and full still 409 for any
  PATCH (no dispatcher).
- Replaced _FLIPPABLE_BOOT_MODES with _DISPATCHER_BOOT_MODES and
  _EDGE_OVERRIDE_BOOT_MODES to make the two gates explicit.

Coordinator:
- _reconcile_from_hint now checks whether the hint's target mode can
  safely take effect on this deployment. Incompatible hints (e.g. a
  former edge-boot pod's hint replayed on a shadow-boot deploy) are
  discarded and boot_reconcile_status is set to the new INCOMPATIBLE_HINT
  value, surfaced via /health. Without this guard, shadow-boot pods
  would boot with override=edge in state while the transport layer
  refused to honor it — misleading diagnostics that required the
  escape-hatch PATCH to clear.
- Added _deployment_allows_mode helper that mirrors the safety-invariant
  checks in version.py (lazy-imported to avoid the version→settings cycle).

Tests:
- test_patch_mcp_mode_shadow_clears_stale_override_on_shadow_boot
  exercises the escape hatch end-to-end.
- test_reconcile_from_hint_discards_incompatible_mode verifies the
  coordinator refuses to apply a shadow-boot-incompatible hint.
- test_reconcile_from_hint_accepts_shadow_mode_on_shadow_boot covers
  the positive case (shadow is always compatible).

Docs updated to explain the per-target-mode gating and the
INCOMPATIBLE_HINT status.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): discard stranded hints on boot=off and boot=full pods

Codex stop-time review caught that the previous _deployment_allows_mode
check returned True for shadow unconditionally — but boot=off has no
Rust sidecar at all and boot=full mounts a plain RustMCPRuntimeProxy
with no dispatcher, so neither boot mode has a mechanism that could
observe an override. A shadow hint replayed onto either pod would
strand in state (effective_mode=shadow in diagnostics; transport keeps
routing to its boot-determined target), and the router 409s for any
PATCH on those boots so the operator would have no API path to clear it.

Tighten _deployment_allows_mode to match the router's admit logic:
shadow accepts only when a dispatcher is mounted (boot=shadow or edge
for MCP; boot=shadow or edge for A2A), and edge still requires the
session-auth-reuse / delegate-enabled invariant.

Three new tests:
- test_reconcile_from_hint_discards_shadow_on_full_boot
- test_reconcile_from_hint_discards_any_mode_on_off_boot
- test_reconcile_from_hint_discards_a2a_hint_on_a2a_off_boot

Architecture doc updated to spell out all three INCOMPATIBLE_HINT cases.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): gate live pub/sub on compat check + structured rejection reasons

Third-pass review identified a critical and several important issues:

C1: _listen_loop applied remote pub/sub messages without the same
compatibility check _reconcile_from_hint enforces. A remote pod's
incompatible flip would strand in local state. Mirror the same gate.

I2: _listen_loop's recovery branch fired on any non-exception return,
including get_message returning None. A pubsub broken in a way that
returns None reliably without raising would falsely re-promote
cluster_propagation from DEGRADED to REDIS. Gate the recovery on
message is not None — the existing idle-timeout branch already handles
the legit no-message case.

I1: Document the deliberate "discarded hint left in Redis" choice in
_reconcile_from_hint's comment.

S4+S5+S6 (combined refactor):
- Promote _deployment_allows_mode to return MoveCompatibility enum
  with structured rejection reasons (NO_DISPATCHER, BOOT_FULL_STRANDS,
  EDGE_NEEDS_SAFETY_FLAG, OK).
- Split BootReconcileStatus.INCOMPATIBLE_HINT into three granular
  values (INCOMPATIBLE_NO_DISPATCHER, INCOMPATIBLE_BOOT_FULL,
  INCOMPATIBLE_SAFETY_FLAG) so /health distinguishes the cases.
- Move the helper to version.py next to the safety-invariant functions
  with a new public deployment_allows_override_mode wrapper.
- Router now calls deployment_allows_override_mode directly, replacing
  the parallel _DISPATCHER_BOOT_MODES/_EDGE_OVERRIDE_BOOT_MODES sets
  with a single source of truth. The 409 detail string is generated
  from the MoveCompatibility reason and names the exact env var or
  flag the operator needs to flip.

Tests added (I3 + I4 + I5 + S1 + S2):
- test_reconcile_from_hint_discards_edge_on_full_boot
- test_reconcile_from_hint_discards_edge_on_off_boot
- test_incompatible_hint_does_not_delete_redis_key_or_downgrade_propagation
  (S1 + S2: assert hint key untouched AND cluster_propagation stays REDIS)
- test_patch_mcp_mode_edge_409_when_boot_mode_full
- test_deployment_allows_override_mode_mcp_table (8 parametrized rows)
- test_deployment_allows_override_mode_a2a_table (6 parametrized rows)

Test fixtures (edge_boot, off_boot, full_boot, shadow_boot) now patch
the underlying settings instead of just monkeypatching the boot-mode
helpers — the new deployment_allows_override_mode inspects raw
settings, so fixture drift would have masked real bugs.

Architecture doc updated to enumerate the three INCOMPATIBLE_* values
and document the live pub/sub compat check.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(runtime): drop self-contradicting 'full' recommendation from NO_DISPATCHER 409

Codex stop-time review caught that the NO_DISPATCHER 409 detail told
operators "Boot with RUST_..._MODE='shadow' or 'edge' (or 'full' for
MCP) to enable overrides." But boot=full returns BOOT_FULL_STRANDS
for the same reason (no dispatcher mounted) — switching to full would
just trade one 409 for another. Worse, A2A has no 'full' mode at all,
so the parenthetical was incoherent for A2A 409 responses.

Drop the "(or 'full' for MCP)" clause. Add an inline comment explaining
why full is intentionally excluded so a future maintainer doesn't
re-add it. Two existing tests now also assert "'shadow' or 'edge'" is
present and "'full'" is absent — pins the contract.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(secrets): refresh detect-secrets baseline after rebase onto main

Line numbers in .secrets.baseline are tied to file content. After
rebasing this branch onto main (which itself touched .secrets.baseline
in upstream commits), the baseline needed a re-scan to match the
post-rebase final file state. No new secrets detected.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): defer generic cluster-wide settings propagation framework (ADR-050)

Records the decision to keep the runtime-mutable mode override
implementation purpose-built for MCP/A2A rather than extracting a
generic ClusterStatePrimitive framework today. Captures the alternative
abstraction sketch for future reference and enumerates the trigger
conditions that would justify revisiting (second concrete consumer in
flight, third independently-tracked use case on the horizon, or a need
for cross-consumer coordination like atomic multi-setting flips).

This is N=1 — CLAUDE.md's "three similar lines is better than a
premature abstraction" applies. The right shape will emerge from
copy-modifying for the second consumer and diffing the result.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): scrub AI-assistant name from ADR-050

Repo guideline forbids naming AI assistants in committed diffs. The
rationale section in ADR-050 cited 'Codex' as the source of stop-time
review iterations; rephrase to describe the review process without
naming the tool. Also drop the explicit 'CLAUDE.md' file citation in
favor of 'repo convention' since the supporting quote stands on its
own.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* docs(adr): tighten ADR-050 claims, deliver defenses checklist, add 0049 row

Comment-analyzer and code-reviewer review of ADR-050 surfaced several
claims that would rot, one promised section that didn't exist, and a
missing index row that visually framed ADR-050 as having skipped a
number.

ADR-050 fixes:

- Correct the "≈900 LOC together" claim to ~1.5 kLOC. Actual line counts
  are runtime_state.py 977 + runtime_admin_router.py 489 = 1,466.
- Replace "six stop-time review iterations" with the verifiable "five
  follow-up fix(runtime): commits on top of the initial feat(runtime):"
  so the count is grep-checkable from git log.
- Drop the "repo convention is explicit: three similar lines beats a
  premature abstraction" framing — that line is from contributor tooling,
  not anything written in the repo. The N=1 argument now stands on its
  own merits.
- Replace "the most-reviewed code on the branch" superlative with the
  timeless "received unusually heavy review during initial development."
- Caption the ClusterStatePrimitive code block as a hypothetical sketch
  so readers who grep-arrive mid-document don't think the class exists.
- Move the reverse-proxy WARN attribution from runtime_state.py to
  runtime_admin_router.py (it lives in the router).
- Name LISTEN_LOOP_DEGRADE_THRESHOLD = 5 and
  RUNTIME_STATE_HINT_TTL_SECONDS = 24 * 60 * 60 in the context section
  so future drift in those constants is detectable from this ADR.
- Reconcile trigger-#1's "build both consumers side-by-side" with the
  rationale's "copy-modify" guidance: copy-modify first, then extract
  the framework from the resulting two-implementation diff.
- Deliver the "non-obvious defenses" checklist that the Negative
  consequence promised but never wrote. The new "Defenses checklist for
  the copy-modify path" section enumerates 12 defenses, each tied to a
  bug class caught during this PR's review iterations.

Index fix:

- Add the missing 0049 row for "Multi-Protocol Virtual Servers"
  (Accepted, Architecture, 2026-04-16). The 048→050 jump made ADR-050
  look like it had skipped a number when ADR-049 was just un-indexed.
- Other duplicate ADR numbers on disk (005, 034, 038, 048) and other
  index gaps are pre-existing and tracked separately in #4290.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* feat(transports): swappable MCPIngressMount + nginx-style Rust public proxy (ADR-051)

Replace MCPStreamableHTTPModeDispatcher (closed-set, two transports,
policy baked into dispatch) with MCPIngressMount: a thin ASGI
indirection that holds a registry of named ingresses + a swappable
selector. Adding a new ingress is one register() call; selection
policy is one function.

Three ingresses registered today:

- "python" — the existing MCPRuntimeHeaderTransportWrapper. Always
  registered.
- "rust-internal" — the existing RustMCPRuntimeProxy that forwards
  via httpx to MCP_RUST_LISTEN_HTTP / UDS (trusted-internal listener).
  Registered for boot=shadow/edge.
- "rust-public" — new RustMCPPublicProxyApp that does nginx-style
  reverse-proxy to MCP_RUST_PUBLIC_LISTEN_HTTP. Adds X-Forwarded-*,
  RFC 7239 Forwarded, X-Real-IP. Streams both directions, preserves
  Authorization/Cookie. Registered only for boot=edge.

Selector chooses between rust-internal and rust-public based on the
new settings.mcp_rust_ingress (default "internal" preserves prior
behavior bit-for-bit). Selector preserves all existing safety
invariants — _should_mount_public_rust_transport() is consulted first,
shadow override / boot=full / boot=off behave identically to before,
the runtime-mode override admin API requires no changes.

Test results: 1839 passing across the touched modules. Lint clean.

ADR-051 documents the design decision, alternatives considered, and
migration notes from MCPStreamableHTTPModeDispatcher. Index updated.

Signed-off-by: Jonathan Springer <jps@s390x.com>

* fix(transports): close XFF spoof + harden Rust public proxy ingress

Response to review of 19677706b (MCPIngressMount + nginx-style Rust
public proxy). Closes a security issue and several diagnostic gaps in
the public proxy and ingress selector.

Security fix — RustMCPPublicProxyApp:

- _build_forwarded_headers now drops client-supplied Forwarded /
  X-Forwarded-* / X-Real-IP from the inbound iteration before
  re-deriving them from request.client. The prior code appended to
  the client-supplied X-Forwarded-For, letting any caller spoof their
  own source IP to Rust's /_internal/mcp/authenticate callback in the
  no-nginx-in-front deployment this proxy targets. New
  _FORWARDED_CHAIN_HEADERS frozenset enumerates the strip set.
- _format_forwarded_for emits RFC 7239 §6.3-conformant `for=unknown`
  (no synthetic `:0`) for absent clients, brackets IPv6 addresses
  exactly once, and tolerates already-bracketed input.

Selector hardening — _select_mcp_ingress / config:

- mcp_rust_ingress is now Literal["internal", "public"] so Pydantic
  rejects typos at boot.
- Selector only returns "rust-public" when boot_mcp_runtime_mode() ==
  "edge" — the public listener isn't bound on shadow boot, so a stale
  edge override + mcp_rust_ingress=public combination would otherwise
  return an unregistered name and silently fall back to Python at
  debug-only log severity.
- _build_mcp_transport_app emits a logger.error (not warning) when
  mcp_rust_ingress=public is set on shadow boot, so the misconfig
  survives the project-default LOG_LEVEL=ERROR.

Error handling — RustMCPPublicProxyApp:

- __call__ now distinguishes asyncio.CancelledError (re-raised),
  httpx.HTTPError (logged with type name + 502), and any other
  exception (logger.exception + 500), so a non-HTTPError no longer
  bypasses the handler and produces a silent Starlette 500.
- _body_iter logs CancelledError at debug (common for SSE client
  disconnect) and other exceptions at error (rare upstream death),
  before re-raising. The finally still calls aclose to return the
  upstream connection to the pool.
- Auth-relevant or server-error upstream statuses (401/403/5xx) now
  log a warning with method+path+status so a credential-rotation flood
  is visible without raising the global log level.

MCPIngressMount diagnostics:

- dispatch's selector-miss paths (fallback used, 503-on-no-fallback)
  now log warning with the unknown name AND self.names(), instead of
  debug-only on the fallback path with no log on the 503 path. A
  misrouted ingress should never be silent.

Tests:

- New tests/unit/mcpgateway/transports/test_rust_mcp_public_proxy.py
  (24 tests). Covers _format_forwarded_for parametrized over IPv4
  with/without port, IPv6, already-bracketed IPv6, None/empty host;
  _build_forwarded_headers spoof rejection + hop-by-hop strip + auth
  preservation + unknown-client emission; proxy ASGI behavior across
  status passthrough, 502 on httpx.HTTPError, 500 on unexpected,
  parametrized warning on (401, 403, 500, 503), non-HTTP scope → 404,
  CancelledError re-raise on send and mid-stream debug-branch, lazy
  client lifecycle, multi-chunk SSE streaming, and aclose-on-exit.
- Two new tests in test_mcp_ingress_mount.py pin the warning-level
  log severity on selector-miss + 503 paths.
- Two new tests in test_main_extended.py pin the boot-time error log
  for the shadow + mcp_rust_ingress=public misconfig and the selector
  downgrade to rust-internal under the same condition.

Doc rot fixes:

- mcp_ingress_mount.py module docstring uses the full ADR filename
  (was an ellipsis).
- version.py docstring references MCPIngressMount instead of the
  removed MCPStreamableHTTPModeDispatcher.
- ADR-051 migration notes use test-group descriptors instead of
  pinned line numbers / test names.

Test results: 652 passing across the touched modules. Lint clean
(black, ruff, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

* chore(quality): fix pylint W0706/W2301/R0401 and close coverage gaps

PR review feedback:

Pylint findings:

- W0706 (try-except-raise) at runtime_state.py:933 and
  rust_mcp_public_proxy.py:237: removed the redundant
  ``except asyncio.CancelledError: raise`` blocks. CancelledError is
  BaseException in Python 3.8+, so the surrounding ``except Exception``
  / ``except httpx.HTTPError`` arms never caught it; the explicit
  re-raise was no-op and made pylint complain. Behavior is unchanged
  (asyncio still cancels both coroutines naturally), and the intent is
  preserved as a comment.
- W2301 (unnecessary-ellipsis) at mcp_ingress_mount.py:37: dropped the
  ``...`` from the ASGIApp Protocol's ``__call__`` body — the
  docstring already counts as a body for Protocol methods.
- R0401 (cyclic-import) on the runtime_state ↔ version pair: added
  per-line ``# pylint: disable=cyclic-import`` to the two lazy imports
  in runtime_state.py. Pylint follows function-body imports for cycle
  detection; the lazy imports break the runtime cycle but pylint still
  flags the static graph. The full extract (move MoveCompatibility +
  coercers to a shared module) is bigger refactor than this PR
  warrants.

Coverage gaps closed (per-file delta):

- routers/runtime_admin_router.py 97.3% → 100%. Added three tests for
  ``_warn_if_behind_reverse_proxy``: header detection emits warning
  with header names, no warning when no forwarded headers, defensive
  early return when ``request.headers`` is None.
- transports/mcp_ingress_mount.py 94.7% → 100%. Added test for
  ``set_fallback`` covering swap mid-life and ``None`` restoring
  503-on-miss behavior.
- transports/rust_mcp_public_proxy.py 96.5% → 100%. Added test for
  ``_body_iter`` taking the ``except Exception`` arm on a non-cancel
  mid-stream error: asserts ``logger.exception`` fires with traceback.
- runtime_state.py 89.9% → 99%. Added 9 tests covering: defensive
  ValueError branches in 4 accessor methods (parametrized), idempotent
  ``coordinator.start()``, ``_reconcile_from_hint`` no-redis and
  malformed-mode paths, ``_cleanup_pubsub`` timeout / exception /
  fallback-to-close paths, listen-loop bytes-payload decode + idle
  recovery + malformed-payload + empty-data + uncoerceable-fields
  branches.
- version.py: targeted user-noted gaps (379, 434, 509, 570, 623) all
  closed. Added tests for last_change population in
  _mcp_runtime_status_payload / _a2a_runtime_status_payload, A2A
  shadow override forcing _should_delegate_a2a_to_rust False, and
  the boot_mcp_transport_mount public wrapper. Line 509
  (NO_DISPATCHER fallthrough — _coerce_runtime rejects unknown kinds
  upstream) marked ``# pragma: no cover`` as a defensive default.

Other defensive defaults marked ``# pragma: no cover``:

- runtime_admin_router.py:99 (OK fallthrough — caller already returned
  for OK upstream).
- runtime_state.py:164, 171 in ``_move_compat_to_reconcile_status``
  (OK case shouldn't reach here per docstring; final return is a
  defensive default for future MoveCompatibility variants).

Test results: 1272 passing across the touched modules. Lint clean
(black, ruff, pylint 10.00/10, interrogate 100%).

Signed-off-by: Jonathan Springer <jps@s390x.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>

* build(container): consolidate on Containerfile.lite; deprecate others (#4297)

Closes #2613.

The top-level `Containerfile` duplicated the .lite build with weaker layer
caching and shipped build tooling in the runtime image. `Containerfile.scratch`
had no frontend-builder stage (admin UI bundle absent), failed under QEMU
(amd64-only), and had no consumer in CI, docs, or the Makefile. Both are
removed; `Containerfile.lite` is now the only canonical gateway image.

Apply the same layer-consolidation and caching pass across `Containerfile.lite`
and the per-component Containerfiles:

- Merge adjacent RUN steps (microdnf + ln + useradd; profile.d setup + chmod;
  Rust MCP + A2A cargo builds; ...) to reduce layer churn.
- Reorder COPYs from stable to volatile so frontend/Rust-binary changes no
  longer invalidate the heavy venv-install layer.
- Use COPY --chown=1001:0 in the UBI nodejs frontend-builder stage — fixes
  EACCES on package-lock.json under the non-root UBI default user — and
  COPY --chmod=0755 on run scripts to drop follow-up chmod layers.
- Replace \`npm install --frozen-lockfile\` (a yarn/pnpm flag npm silently
  ignores) with \`npm ci\`.
- Silence pre-existing hadolint DL3013/DL3041 findings with targeted ignore
  comments consistent with the file-header policy.

Update every touchpoint of the deprecated images: Makefile podman/docker[-dev]
targets, fly.toml, docker-compose commented migration examples, the
docker-scan workflow matrix and trigger paths, .travis.yml,
.pre-commit-config.yaml, .bumpversion.cfg, MANIFEST.in, and user-facing docs
(packaging, release-management, compose, openshift, container, fly-io, argocd
tutori…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants