Skip to content

feat(a2a): add /v1 task and push notification config routes - #7372

Merged
msureshkumar88 merged 3 commits into
mainfrom
6702-a2a-v1-task-routes
Oct 30, 2026
Merged

msureshkumar88 merged 3 commits into
mainfrom
6702-a2a-v1-task-routes

Conversation

@marekdano

Copy link
Copy Markdown
Collaborator

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:

Method Path Permission
GET /v1/a2a/{agent_id}/tasks a2a.read
GET /v1/a2a/{agent_id}/tasks/{task_id} a2a.read
POST /v1/a2a/{agent_id}/tasks/{task_id}/cancel a2a.invoke
POST .../{task_id}/pushNotificationConfigs a2a.update
GET .../{task_id}/pushNotificationConfigs a2a.read
GET .../{task_id}/pushNotificationConfigs/{config_id} a2a.read
DELETE .../{task_id}/pushNotificationConfigs/{config_id} a2a.update
  • The task list takes pageSize and pageToken and returns nextPageToken, which is empty on the last page. The token is an opaque offset. A malformed token returns 400.
  • Every route uses the same Layer 1 visibility scoping as the internal handlers. A task the caller cannot see returns 404.
  • Push config routes check task visibility first, because list_push_configs does no visibility filtering. A config that belongs to another task returns 404.
  • Push config routes take the agent and task IDs from the path, never from the body. The new A2APushNotificationConfigRequest schema keeps the SSRF webhook URL validation. Responses never include auth_token.
  • TokenScopingMiddleware maps the new paths. Without the mapping, DELETE would require a2a.delete and cancel and config creation would be unmapped.
  • CHANGELOG.md and .secrets.baseline are updated. The baseline change is line-number shifts in main.py.

The /_internal/a2a/* routes and service methods are unchanged.

Design notes

  • Routes nest under the agent. a2a_tasks is unique on (a2a_agent_id, task_id), so a flat /tasks/{task_id} path would be ambiguous when two agents share a task_id. A2A 1.0's REST binding uses flat paths. A gateway-level alias is a possible follow-up.
  • Pagination is offset-based behind an opaque token. A keyset cursor needs a stable sort key that the current updated_at ordering 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

  • Tests cover pagination, deny paths (hidden task, foreign config, SSRF URL, disabled service), and the permission mapping
  • make pre-commit ruff interrogate pylint verify pass
  • CHANGELOG entry added

  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>
Lang-Akshay
Lang-Akshay previously approved these changes Oct 29, 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 ✅

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

Comment thread mcpgateway/main.py Resolved

@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 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>
@marekdano
marekdano force-pushed the 6702-a2a-v1-task-routes branch from b6aaf96 to 4d9abba Compare October 30, 2026 09:50
@marekdano

marekdano commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

@lucarlig @msureshkumar88

Changes by reviewer item

  • Page tokens (lucarlig blocker): The pageToken is now a keyset cursor {"u": updated_at, "i": row id}. Every malformed token returns 400, including the 1e309 and 2**64 offsets from the review.
  • Pagination tiebreaker: List order is now (updated_at DESC, id DESC). The new service method list_tasks_page() pages by cursor. A test cancels a task between pages and confirms no rows are skipped or duplicated.
  • Authorization: Tasks have no owner column, so the PR takes the second option from the review and adds no migration. Task routes and push-config routes now require the caller to own the agent, be a team owner of it, or be a platform admin. Other callers get 404, and the denial is logged. The check is A2AAgentService.check_agent_manage_access().
  • List responses: List responses omit history and artifacts.
  • Config lookup: Config get and delete use one query scoped by agent, task, and config ID.
  • Audit: Cancel and config create/delete write audit entries, without db=.
  • Status codes: Re-registering an existing webhook URL returns 200 instead of 201.
  • State filter: The state query parameter validates against A2ATaskState.
  • Docs: The cancel route's docstring says it changes gateway state only. docs/docs/using/agents/a2a.md has a new "Task and Push Notification Routes" section.
  • Single-agent list path: The list path no longer builds the visible-ID set for all agents.

Tests

  • tests/unit/mcpgateway/test_a2a_task_routes.py: real-SQLite deny-path tests (non-owner, public-only token, config overwrite and delete blocked), keyset pagination tests, and page-token validation tests.
  • tests/live_gateway/a2a/test_a2a_task_routes_e2e.py: list, get, cancel, and push config CRUD against a live gateway, plus a second-user denial check.

Decision for the reviewers

Tasks use owner-only access. The alternative is an owner_email column on a2a_tasks with an Alembic migration. That would let any caller who can see a public agent read their own tasks.

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

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 in a2a.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, A2ATaskState validation, audit entries without db=, 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.

@msureshkumar88
msureshkumar88 added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 5da4331 Oct 30, 2026
40 checks passed
@msureshkumar88
msureshkumar88 deleted the 6702-a2a-v1-task-routes branch October 30, 2026 11:37
@prakhar-singh1928 prakhar-singh1928 mentioned this pull request Nov 10, 2026
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]: A2A task, event, and push notification data has no client-reachable endpoint

4 participants