Skip to content

feat(a2a): return a typed 404 when A2A is disabled and expose feature… - #7368

Open
marekdano wants to merge 7 commits into
mainfrom
6698-a2a-enabled-discovery
Open

marekdano wants to merge 7 commits into
mainfrom
6698-a2a-enabled-discovery

Conversation

@marekdano

@marekdano marekdano commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #6698

Summary

A client had no unprivileged way to tell whether A2A is enabled. When A2A is disabled, each route answered differently.

This PR gives every A2A route one disabled response. It also exposes the enabled flag to any authenticated user.

  • The A2A router is always mounted. A router dependency returns the disabled response when A2A is off.
  • The dependency runs after authentication. Only authenticated callers learn that A2A is off.
  • GET /auth/email/me returns features: {"a2a": <bool>}. It uses a new CurrentUserResponse that extends EmailUserResponse. Other endpoints that return EmailUserResponse do not change.

Disabled contract

Every A2A route returns 404 with this body:

  {"detail": {"error": "a2a_disabled", "message": "A2A is disabled on this gateway"}}

Clients branch on detail.error.

Before this change, /a2a/* and /v1/a2a/* returned a generic {"detail": "Not Found"} because the router was not mounted. The 503 branches in the handlers never ran.

Route Before After
/a2a/*, /v1/a2a/* 404, generic body 404, typed body
GET /admin/a2a 200, empty page 404, typed body
POST /admin/a2a 403, success: false 404, typed body
POST /admin/a2a/{agent_id}/test 403, success: false 404, typed body
POST /metrics/reset, entity a2a 400 404, typed body

The admin state, edit, and delete form routes still redirect to the A2A tab. They are HTML form flows, and the tab is hidden when A2A is off.

POST /metrics/reset for entity a2a still returns 400 when A2A is on and MCPGATEWAY_A2A_METRICS_ENABLED is false.

Consumer impact

  • Clients that read the old 403 or 400 on the admin and reset routes must read 404 and detail.error.
  • Clients that read an empty page from GET /admin/a2a when A2A is off now receive a 404.
  • The empty-list response is removed, because it looked the same as "no agents exist".
  • The nine 503 branches in the main.py handlers stay as a defensive fallback. They cannot run when A2A is disabled.

Manual test against a running gateway

Create two tokens:

  export TOKEN=$(python -m mcpgateway.utils.create_jwt_token --username admin@example.com --exp 10080 --secret "$JWT_SECRET_KEY")
  export ADMIN_TOKEN=$(python -m mcpgateway.utils.create_jwt_token --username admin@example.com --admin --exp 10080 --secret "$JWT_SECRET_KEY")

The admin rows need MCPGATEWAY_ADMIN_API_ENABLED=true and ADMIN_TOKEN. The admin router rejects other tokens with 403 before the A2A check runs.

A2A disabled

Start the gateway with MCPGATEWAY_A2A_ENABLED=false make dev.

Case Request Expected
List agents curl -s -i localhost:8000/v1/a2a -H "Authorization: Bearer $TOKEN" 404, detail.error is a2a_disabled
Get agent curl -s -i localhost:8000/v1/a2a/any-id -H "Authorization: Bearer $TOKEN" Same 404 and body
Invoke curl -s -i -X POST localhost:8000/v1/a2a/any-name/invoke -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' Same 404 and body
Unauthenticated curl -s -i localhost:8000/v1/a2a 401, not the typed 404
Admin list curl -s -i localhost:8000/v1/admin/a2a -H "Authorization: Bearer $ADMIN_TOKEN" 404, typed body, not an empty page
Admin create curl -s -i -X POST localhost:8000/v1/admin/a2a -H "Authorization: Bearer $ADMIN_TOKEN" 404, typed body
Admin test curl -s -i -X POST localhost:8000/v1/admin/a2a/any-id/test -H "Authorization: Bearer $ADMIN_TOKEN" 404, typed body
Admin list, non-admin token curl -s -i localhost:8000/v1/admin/a2a -H "Authorization: Bearer $TOKEN" 403 Admin privileges required
Metrics reset curl -s -i -X POST "localhost:8000/v1/metrics/reset?entity=a2a" -H "Authorization: Bearer $ADMIN_TOKEN" 404, typed body
Feature flag curl -s localhost:8000/v1/auth/email/me -H "Authorization: Bearer $TOKEN" features is {"a2a": false}

A2A enabled

Start the gateway with make dev.

Case Request Expected
Feature flag curl -s localhost:8000/v1/auth/email/me -H "Authorization: Bearer $TOKEN" features is {"a2a": true}
List agents curl -s localhost:8000/v1/a2a -H "Authorization: Bearer $TOKEN" 200, agent list
Metrics reset, metrics on curl -s -i -X POST "localhost:8000/v1/metrics/reset?entity=a2a" -H "Authorization: Bearer $ADMIN_TOKEN" 200 status: success
Metrics reset, metrics off Restart with MCPGATEWAY_A2A_ENABLED=true MCPGATEWAY_A2A_METRICS_ENABLED=false make dev, then curl -s -i -X POST "localhost:8000/v1/metrics/reset?entity=a2a" -H "Authorization: Bearer $ADMIN_TOKEN" 400 A2A metrics are disabled, not the typed 404

Result from manual testing

Screenshot 2026-10-30 at 11 26 49 Screenshot 2026-10-30 at 11 27 06

Lang-Akshay
Lang-Akshay previously approved these changes Nov 3, 2026

@Lang-Akshay Lang-Akshay 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 ✅

@msureshkumar88 msureshkumar88 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.

The direction is right. The router stays mounted, a dependency returns one typed 404 after authentication, and /auth/email/me exposes the flag. No Alembic migration is needed. Unauthenticated callers still get 401. I request changes because the PR does not yet cover every A2A route, as the description and issue criterion 1 require.

Blocking

blocking | mcpgateway/admin.py:16290 | GET /admin/a2a/{agent_id} has no disabled guard. When A2A is off, a2a_service is None. The call to a2a_service.get_agent(...) raises AttributeError, the handler re-raises it, and the client gets a 500. | Add if a2a_service is None: raise HTTPException(status_code=404, detail=dict(A2A_DISABLED_DETAIL)).

blocking | mcpgateway/admin.py:11239, 11453, 11513 | /admin/a2a/partial, /admin/a2a/ids, and /admin/a2a/search do not change. /ids and /search read the database and return 200 when A2A is off. /partial calls a2a_service.convert_agent_to_read and fails with a 500 when agents remain in the database. | Apply the same guard, or add one dependency for all /admin/a2a* routes. Add a test for each route.

Functionally impacting

functionally-impacting | tests/live_gateway/a2a/ | The PR has no black-box test against a running gateway. The repo requires one. The existing live tests run with A2A enabled. | Add a live test with MCPGATEWAY_A2A_ENABLED=false. Cover /v1/a2a, /v1/a2a/{id}, invoke, the admin routes, POST /metrics/reset?entity=a2a, and /me.features.

functionally-impacting | docs/docs/using/agents/a2a.md:26, docs/docs/manage/api-usage.md:1781 | The docs do not describe the a2a_disabled contract or features on /me. This change breaks consumers, so the docs must state the new contract. | Document both. Add the old-to-new status table from the PR description.

functionally-impacting | mcpgateway/main.py:3636, mcpgateway/admin.py:16374, 16421, 16952, mcpgateway/routers/email_auth.py:572 | Three predicates decide "disabled": a2a_service is None, not a2a_service or not settings.mcpgateway_a2a_enabled, and settings.mcpgateway_a2a_enabled. admin.py:883 also keeps a separate a2a_service. They agree in production and diverge when a test patches one of them. | Add one a2a_enabled() helper in a2a_service.py and use it in all of these places.

functionally-impacting | mcpgateway/main.py:4962, 5026, 5102, 5159, 5214, 5276, 5425, 5735, 5787, 5965 | Ten 503 "A2A service not available" branches are now unreachable. The PR description says nine. They contradict the new contract and have no coverage, because the updated tests hit the dependency first. | Delete them, or raise A2A_DISABLED_DETAIL with a 404.

functionally-impacting | mcpgateway/api/v1/__init__.py:134 | The router is always mounted, so OpenAPI lists every A2A operation on a deployment where all of them return 404. | Use include_router(a2a_router, include_in_schema=settings.mcpgateway_a2a_enabled). Apply the same change to the legacy /a2a mount.

functionally-impacting | mcpgateway/routers/email_auth.py:554, mcpgateway/middleware/token_scoping.py:1320 | Issue criterion 2 requires that any authenticated non-admin can read the flag in one call. /auth/email/me is not in the token-scoping skip_paths. I did not confirm that team-scoped API tokens and SSO sessions can reach it. | Add a test for a team-scoped token calling /me. If the call is blocked, fix the scoping so the criterion holds.

Suggestions and minor

suggestion | mcpgateway/schemas.py:7121 | features: Dict[str, bool] is open-ended. Every flag added later becomes visible to every authenticated user. | Use a typed model or an explicit allowlist.

suggestion | mcpgateway/main.py:3623 | The dependency resolves get_current_user_with_permissions again. FastAPI deduplicates it only when the handlers use the same dependency. | Assert the call count in a test.

minor | mcpgateway/main.py:12591 | The /metrics/reset 400 message changes from "A2A features are disabled" to "A2A metrics are disabled". The CHANGELOG says only "still returns 400". | Mention the new message.

minor | mcpgateway/admin.py:16422 | The dependency denial logs nothing. Only the admin create path keeps its warning. | Log denials at one level, preferably in the shared helper.

Tests

The /me test covers the flag in both states. The new tests cover the main.py routes and the three changed admin handlers. These gaps remain:

  • the admin routes in the blocking findings
  • a wrong-permission caller and a non-admin on the admin routes
  • 401 for each route family
  • OpenAPI behavior
  • /me for scoped and SSO tokens

The disabled-mode test depends on importlib.reload of the app and can leak global state between tests.

Breaking change

The PR flags the status changes in the description and the CHANGELOG. To reduce the impact, mark the CHANGELOG entry as breaking. For POST /admin/a2a and POST /admin/a2a/{id}/test, consider keeping success: false in the 404 body for one release, so existing parsers keep working.

I reviewed the diff and the surrounding code. I did not run the test suite or a live gateway.

@marekdano

Copy link
Copy Markdown
Collaborator Author

@msureshkumar88 - Thanks for the review. I pushed fixes for every finding except one, which I deferred with a reason.

Blocking

  • fixed | admin.py | GET /admin/a2a/{agent_id}, /partial, /ids, and /search now return the typed a2a_disabled 404. Unit tests cover each route.

Functionally impacting

  • fixed | Live test | tests/live_gateway/a2a/test_a2a_disabled_e2e.py boots a gateway with MCPGATEWAY_A2A_ENABLED=false. It covers the v1 and admin routes, the metrics reset, OpenAPI, and /me. Each route is checked as admin and as anonymous (401).
  • fixed | Docs | a2a.md and api-usage.md describe the a2a_disabled contract, features on /me, and the old-to-new status table.
  • fixed | Predicates | a2a_enabled() and raise_if_a2a_disabled() in a2a_service.py replace the separate checks in main.py, admin.py, and email_auth.py. The helper logs each denial.
  • fixed | 503 branches | I deleted all ten, and the unit tests that asserted them.
  • fixed | OpenAPI | The A2A router uses include_in_schema tied to the flag. The legacy mount comes from the same factory, but legacy routes do not appear in OpenAPI.
  • fixed | /me and token scoping | A token with explicit scoped permissions got 403 on /auth/email/me. Token scoping now allows GET on that path only. Tests confirm that other methods and sibling auth paths stay denied.

Suggestions and minor

  • fixed | features is now a typed FeatureFlags model.
  • fixed | A test asserts that authentication resolves once per request.
  • fixed | The CHANGELOG entry is marked breaking and states the new reset message.
  • fixed | Denials are logged in the shared helper.
  • fixed | The disabled-mode passthrough test no longer uses importlib.reload.

Deferred

  • Keeping success: false in the 404 body for POST /admin/a2a and POST /admin/a2a/{id}/test. The old and new responses are both non-2xx, and a top-level key would give those two routes a different body shape from every other A2A route. I can add it for one release if you want it.

Added live tests for the remaining gaps:

  • A token scoped to unrelated permissions gets 403 on every admin A2A route.
  • A plain non-admin user holds a2a.read, so the admin GET routes return the typed 404. The admin POST routes return 403.
  • GET /auth/email/me returns features.a2a for a scoped API token and for a session token with an SSO claim. I did not test a real SSO provider login.

@marekdano
marekdano force-pushed the 6698-a2a-enabled-discovery branch from 15a1dba to 8560798 Compare November 11, 2026 09:58

@msureshkumar88 msureshkumar88 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.

Thanks for the thorough follow-up. I checked each finding from my previous review against 856079828. The fixes are in place:

  • GET /admin/a2a/{agent_id}, /partial, /ids, and /search return the typed 404.
  • a2a_enabled() and raise_if_a2a_disabled() replace the separate predicates and log each denial.
  • The ten unreachable 503 branches are gone.
  • OpenAPI omits the A2A routes when A2A is off.
  • Token scoping allows GET /auth/email/me for any token. The path is matched after the /v1 prefix is stripped.
  • features is a typed FeatureFlags model.
  • The CHANGELOG entry is marked breaking and states the new reset message.
  • The docs and the live test tests/live_gateway/a2a/test_a2a_disabled_e2e.py are added.

The deferral of success: false is fine with me.

CI is green. Locally, the nine changed unit test files pass (3195 passed, 1 skipped). I did not run the live test.

I request changes for one remaining route.

Blocking

blocking | mcpgateway/admin.py:16964 | POST /admin/a2a/{agent_id}/edit has no disabled guard. When A2A is off, a2a_service is None. The call to a2a_service.update_agent(...) raises AttributeError, the generic handler catches it, and the client gets a 500. The CHANGELOG and the PR description say that this route still redirects to the A2A tab. | Add raise_if_a2a_disabled() at the top of the handler. The route returns JSON, so the typed 404 fits better than a redirect. Correct the CHANGELOG sentence and add a unit test.

I reproduced the failure with a unit-level call to admin_edit_a2a_agent with A2A disabled and a valid form:

AttributeError: 'NoneType' object has no attribute 'update_agent'
500 {"message":"An unexpected error occurred. Please try again or contact support.","success":false}

This gap existed before this PR, and my first review missed it. It still blocks issue #6698 criterion 1 (every A2A route), and the CHANGELOG states the wrong behavior.

Suggestions and minor

suggestion | mcpgateway/admin.py:18000, 18060, 18137 | The /admin/a2a/plugin-bindings* routes have no disabled guard. They do not use a2a_service, so they probably return 200 when A2A is off. I did not test this. | Guard them, or list them as exempt in the CHANGELOG.

minor | mcpgateway/admin.py:4606, mcpgateway/main.py:12629, 12661 | These sites still use a2a_service and settings... instead of a2a_enabled(). They behave correctly. | Use the helper for consistency.

…s on /me

  Mount the A2A router in all cases. A router dependency returns 404
  with detail.error "a2a_disabled" when A2A is disabled. The
  dependency runs after authentication, so only authenticated callers
  learn the flag.

  The admin list, create and test routes and POST /metrics/reset for
  entity a2a return the same response. Before, they returned an empty
  page, 403, 403 and 400. The admin state, edit and delete form routes
  still redirect to the A2A tab.

  GET /auth/email/me adds features {"a2a": <bool>}. Other endpoints
  that return EmailUserResponse do not change.

Signed-off-by: Marek Dano <mk.dano@gmail.com>
  The A2A router is always mounted and answers with a typed 404 when
  A2A is disabled. Authentication runs before the flag check.

  Expect 404 with detail.error "a2a_disabled" instead of 503 when the
  service is unavailable. In the feature-flag test, override the auth
  dependency on the reloaded app and assert 401 for an unauthenticated
  caller and 404 for an authenticated caller.

Signed-off-by: Marek Dano <mk.dano@gmail.com>
Signed-off-by: Marek Dano <mk.dano@gmail.com>
  Guard the admin A2A read routes with the typed a2a_disabled 404.
  Add a2a_enabled() and raise_if_a2a_disabled() as the single disabled check.
  Delete the unreachable 503 branches in the A2A handlers.
  Hide A2A operations from OpenAPI when A2A is disabled.
  Type the /auth/email/me features field as FeatureFlags.
  Let any token read GET /auth/email/me in token scoping.
  Document the a2a_disabled contract and mark the CHANGELOG entry breaking.
  Add unit tests and a live gateway test for disabled mode.
  Drop importlib.reload from the disabled-mode passthrough test.

Signed-off-by: Marek Dano <mk.dano@gmail.com>
Signed-off-by: Marek Dano <mk.dano@gmail.com>
…disabled

Signed-off-by: Marek Dano <mk.dano@gmail.com>
@marekdano
marekdano force-pushed the 6698-a2a-enabled-discovery branch from 8560798 to 2994579 Compare November 11, 2026 12:01

This branch has not been deployed

No deployments
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.

[API][UI-REWRITE]: No unprivileged way to detect whether A2A is enabled, and disabled responses are inconsistent

3 participants