Repository navigation
feat(a2a): add /v1 task and push notification config routes - #7372
Conversation
Clients can now read and manage A2A task data. Before, only the
internal /_internal/a2a/* routes served it.
Add these routes under /v1/a2a/{agent_id}/tasks:
- GET lists tasks with pageSize, pageToken, and nextPageToken.
- GET /{task_id} returns one task.
- POST /{task_id}/cancel cancels a task.
- POST, GET, and DELETE on /{task_id}/pushNotificationConfigs manage
push notification configs.
Reads require a2a.read, cancel requires a2a.invoke, and config writes
require a2a.update. Every route applies the same team and visibility
scoping as the internal handlers and returns 404 for tasks the caller
cannot see. Push config routes take the agent and task IDs from the
path and never return auth_token. Map the new paths in
TokenScopingMiddleware so token scopes match the route permissions.
The internal routes are unchanged.
Signed-off-by: Marek Dano <mk.dano@gmail.com>
lucarlig
left a comment
There was a problem hiding this comment.
The routes reuse the existing service and visibility helpers, take identifiers from the path, and exclude webhook credentials from responses. Requesting changes for numeric page-token validation; details are inline.
Reviewed commit b6aaf96. Isolated checks using the extracted route functions and SQLite reproduce HTTP 500 for both numeric offsets described below.
msureshkumar88
left a comment
There was a problem hiding this comment.
The routes take IDs from the path, exclude auth_token from responses, and map the new paths in TokenScopingMiddleware. Requesting changes for two authorization gaps. The pageToken blocker from lucarlig stands, and I add detail below. Reviewed commit b6aaf96b8. I ran the PR tests (22 pass) and probed the service layer with SQLite.
blocking | mcpgateway/main.py:5506,5594 | Push-config create and delete check only task visibility and a2a.update. Agent update requires ownership (check_resource_ownership, a2a_service.py). Any caller who can see a public or team agent can register, overwrite, or delete another user's webhook config. | Require agent ownership or team admin for config writes. Add deny tests.
create_push_config upserts on (agent, task, webhook_url). A second caller who posts the same URL changes auth_token, events, and enabled of the existing config.
blocking | mcpgateway/main.py:5402-5470 | a2a_tasks has no owner column. List, get, and cancel return and cancel every caller's task on a visible agent, including payload.history. | Choose one before merge: add a2a_tasks.owner_email with an Alembic migration and filter by it, or restrict list, get, and cancel to the agent owner or team admin and document the model.
Probe result: a public-only token (teams=[]) listed another user's task and read its history. The same caller registered https://attacker.example/hook on that task and set it to canceled. CWE-639, CWE-862, CWE-200.
blocking | mcpgateway/main.py:5387 | Agree with lucarlig. Offsets 1e309 and 2**64 return HTTP 500. | Accept only int (reject bool, float, str) in 0 <= o <= 2**31-1, else 400. Add regression tests.
Today {"o":1.5} decodes to 1, {"o":true} to 1, and {"o":"7"} to 7.
functionally-impacting | mcpgateway/services/a2a_service.py:3617 | ORDER BY updated_at DESC has no tiebreaker. Cancel and upsert bump updated_at, so offset paging skips and duplicates rows. #6702 asks for cursor-based pagination. | Order by (updated_at DESC, id DESC) and encode both values in the token. No migration needed.
functionally-impacting | tests/unit/mcpgateway/test_a2a_task_routes.py:20 | The service is mocked and the caller is always admin. Deny-path tests required by AGENTS.md are missing: wrong team, public-only token, missing a2a.update or a2a.invoke. | Add real-DB deny tests.
functionally-impacting | tests/live_gateway/ | No black-box test against a live gateway. | Add one: seed a task, run list, get, cancel, and config CRUD, and confirm a second user is denied.
functionally-impacting | mcpgateway/main.py:5402 | List responses carry full history and artifacts for up to 100 tasks (CWE-770). | Omit them from list responses or cap them.
functionally-impacting | mcpgateway/main.py:5560-5628 | Config get and delete load all configs of the task and filter in Python. | Add a service lookup scoped by agent, task, and config ID in one query.
functionally-impacting | mcpgateway/main.py:5506-5628 | New mutating routes log nothing on visibility denial, cancel, or config writes. Internal handlers log denials. | Log denials. Add an audit entry for cancel and config writes. Do not pass db= to log_action().
suggestion | mcpgateway/main.py:5593 | The route returns 201 when the upsert updates an existing config. | Return 200 on update.
suggestion | mcpgateway/main.py:5402 | With agent_id in the path, list_tasks builds the visible IDs of all agents. | Check the single agent with _check_agent_access_by_id.
suggestion | mcpgateway/main.py | Handlers return bare dicts, state is free text, and the cancel route changes local state only. | Add response models, validate state, and document the cancel behavior and the endpoints in docs/.
No Alembic migration is needed for the PR as written. The first owner-column option above would need one.
Address review feedback on #7372. - Require agent ownership, team ownership, or platform admin for all task and push notification config routes. Return 404 to other callers and log the denial. - Replace offset page tokens with a keyset cursor on (updated_at, id). Reject malformed tokens with 400. - Order task lists by updated_at and id to give stable pages. - Omit history and artifacts from list responses. - Look up and delete push configs with one query scoped by agent, task, and config ID. - Return 200 when a push config upsert updates an existing config. - Validate the state filter against A2ATaskState. - Write audit entries for cancel and push config create and delete. - Add real-database deny-path tests, a live gateway test, and a docs section for the routes. Signed-off-by: Marek Dano <mk.dano@gmail.com>
b6aaf96 to
4d9abba
Compare
Changes by reviewer item
Tests
Decision for the reviewersTasks use owner-only access. The alternative is an |
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for the thorough follow-up. I re-checked every item from my earlier review against 4d9abba51. All blocking and functionally-impacting items are resolved.
- Push-config writes and task isolation: Every route now goes through
check_agent_manage_access(). A probe confirms that a public-only caller and a team member who does not own the agent both get denied. The owner-only model is documented ina2a.md. That matches the second option from the review, so no migration is needed. - pageToken: The keyset cursor rejects the old overflow offsets, non-string fields, empty or long IDs, bad dates, and invalid base64 with 400.
- Paging:
(updated_at, id)ordering plus the cancel-between-pages test covers the tiebreaker. - Other fixes: I confirmed the 200-on-upsert, scoped config get/delete, summary-only list,
A2ATaskStatevalidation, audit entries withoutdb=, and the docs section. - Tests: The unit suite passes (45). The new live gateway test passes locally (5). CI is green.
One non-blocking follow-up:
minor | mcpgateway/main.py:5588 | Audit details store the full webhook_url. Slack/Discord-style URLs carry the secret in the path | Log scheme and host only, or config_id alone
The earlier optional suggestions are fine to defer: response models, max_length caps on the push-config request schema, and the RBAC doc route list.
Summary
Clients can now read and manage A2A task data. Before, only the trusted
/_internal/a2a/*routes served it, so ContextForge stored tasks and push configs but could not return them.Closes #6702
Part of #5936
Changes
New routes on
a2a_router, served under/v1/a2a/{agent_id}/tasks:/v1/a2a/{agent_id}/tasksa2a.read/v1/a2a/{agent_id}/tasks/{task_id}a2a.read/v1/a2a/{agent_id}/tasks/{task_id}/cancela2a.invoke.../{task_id}/pushNotificationConfigsa2a.update.../{task_id}/pushNotificationConfigsa2a.read.../{task_id}/pushNotificationConfigs/{config_id}a2a.read.../{task_id}/pushNotificationConfigs/{config_id}a2a.updatepageSizeandpageTokenand returnsnextPageToken, which is empty on the last page. The token is an opaque offset. A malformed token returns 400.list_push_configsdoes no visibility filtering. A config that belongs to another task returns 404.A2APushNotificationConfigRequestschema keeps the SSRF webhook URL validation. Responses never includeauth_token.TokenScopingMiddlewaremaps the new paths. Without the mapping, DELETE would requirea2a.deleteand cancel and config creation would be unmapped.CHANGELOG.mdand.secrets.baselineare updated. The baseline change is line-number shifts inmain.py.The
/_internal/a2a/*routes and service methods are unchanged.Design notes
a2a_tasksis unique on(a2a_agent_id, task_id), so a flat/tasks/{task_id}path would be ambiguous when two agents share atask_id. A2A 1.0's REST binding uses flat paths. A gateway-level alias is a possible follow-up.updated_atordering does not provide._require_a2a_service()returns 503 when A2A is disabled. feat(a2a): return a typed 404 when A2A is disabled and expose feature… #7368 changes the disabled response to a typed 404. After that PR merges, this helper becomes redundant and should defer to its router dependency.Checklist
make pre-commit ruff interrogate pylint verifypass