Repository navigation
Conversation
aa1f3af to
7cbc46c
Compare
edf973a to
b87d0be
Compare
vishu-bh
left a comment
There was a problem hiding this comment.
Comments on card fetching, normalization, and authentication mapping.
| body = bytearray() | ||
| async for chunk in response.aiter_bytes(): | ||
| body.extend(chunk) | ||
| if len(body) > MAX_CARD_BYTES: |
There was a problem hiding this comment.
Please bound decompression before checking the card size. HTTPX decompresses chunks before aiter_bytes() yields them. A 32 KB gzip response produced 32 MiB of decoded content and about 81 MB of peak allocation before rejection. Concurrent fetches can exhaust worker memory. Use bounded decompression over aiter_raw(), or request identity encoding and reject compressed responses. Add a compressed-response test.
There was a problem hiding this comment.
Fixed! The request now sends Accept-Encoding: identity and reads aiter_raw(). The fetch discards any response that has a Content-Encoding other than identity, so a small compressed body cannot expand past the 1 MiB cap. I also added a total deadline with asyncio.timeout. New tests cover gzip, br, deflate and zstd responses, the identity request header, and a slow-drip server.
| @a2a_router.post("/fetch-card", response_model=A2AFetchCardResponse) | ||
| @require_permission("a2a.create") | ||
| async def fetch_a2a_agent_card( | ||
| request: A2AFetchCardRequest, |
There was a problem hiding this comment.
Please rename the body parameter and supply a separate FastAPI Request parameter. require_permission forwards the keyword argument request to RBAC. With a permission-check plugin and no pre-request context, RBAC calls request.headers.get(...). The body's optional headers defaults to None, so an ordinary fetch returns HTTP 500 before discovery. Please add a route test with a permission-only plugin and no outbound headers.
There was a problem hiding this comment.
Fixed. The body parameter is now payload, and a separate FastAPI Request parameter goes to require_permission. A route test with a permission-check plugin and no outbound headers checks that RBAC receives the real request.
| continue | ||
| if document is None or not is_agent_card(document): | ||
| return not_found() | ||
| card, version = normalize_card(document) |
There was a problem hiding this comment.
Please validate nested card fields before normalization and mapping. A response such as {"name":"a","url":"https://agent.example.com/rpc","capabilities":"invalid"} passes is_agent_card() and raises an uncaught AttributeError. A v1 card with securityRequirements:["invalid"] fails similarly. Return no_card for invalid upstream documents and add tests for malformed nested fields.
There was a problem hiding this comment.
Fixed. A validation step now runs before normalization and mapping. It checks capabilities, securitySchemes, OAuth flows and scopes, securityRequirements, 0.3 security, skills[] and additionalInterfaces. A malformed card returns no_card. The except that catches type errors is only a backstop. Tests cover 20 malformed 1.0 and 0.3 cards, including your two examples.
| warnings: List[str] = [] | ||
| chosen: Dict[str, Any] = {} | ||
| for requirement in card.get("securityRequirements") or []: | ||
| for name, scopes in (requirement.get("schemes") or {}).items(): |
There was a problem hiding this comment.
Schemes within one security requirement must be used together; separate requirement objects are alternatives. For {"schemes":{"bearer":{},"apiKey":{}}}, this loop suggests bearer alone and labels the required API key an alternative. The resulting registration cannot authenticate. Please evaluate complete requirement groups and warn when a required combination cannot be represented. Add a test containing two schemes in one requirement.
There was a problem hiding this comment.
Fixed! _suggest_auth now treats each requirement object as one group. A group with more than one scheme is skipped, and a warning names the schemes that must be used together. A group with one supported scheme is still selected. Tests cover a two-scheme requirement, and a combined requirement followed by a single-scheme alternative.
| capabilities["extensions"] = legacy_caps["extensions"] | ||
|
|
||
| card: Dict[str, Any] = { | ||
| key: raw[key] for key in ("name", "description", "version", "provider", "documentationUrl", "iconUrl", "defaultInputModes", "defaultOutputModes", "skills", "signatures") if key in raw |
There was a problem hiding this comment.
Please normalize security requirements inside each legacy skill. Copying skills unchanged leaves skills[].security in the response advertised as normalized A2A 1.0. Clients reading skills[].securityRequirements cannot see those requirements. Apply the requirement conversion to each skill while preserving rawCard, and add a legacy fixture with skill-level authentication.
There was a problem hiding this comment.
Fixed! _convert_legacy_skill converts each skill's security to securityRequirements and drops the legacy key. rawCard is unchanged. A legacy fixture with skill-level authentication covers this.
| try: | ||
| candidates = build_candidate_urls(base_url, path) | ||
| except CardPathError: | ||
| return not_found() |
There was a problem hiding this comment.
An unsafe path such as ../etc/passwd returns HTTP 200 with outcome:"no_card" on both aliases. Catching CardPathError here converts invalid client input into a discovery result, contrary to the PR's documented 422 behavior. Please validate the path in A2AFetchCardRequest, or translate this error into HTTP 422. Add route tests for both aliases.
There was a problem hiding this comment.
Fixed! A2AFetchCardRequest validates path and returns 422. The check also rejects percent-encoding, so %2e%2e no longer passes. I added baseUrl checks that reject userinfo, a query and a fragment. The live-gateway test checks 422 on both /a2a/fetch-card and /v1/a2a/fetch-card.
| wanted = scopes.get("list", []) if isinstance(scopes, dict) else [] | ||
| if "clientCredentials" in flows: | ||
| flow = flows["clientCredentials"] | ||
| return {"auth_type": "oauth", "oauth_config": {"grant_type": "client_credentials", "token_url": flow.get("tokenUrl"), "scopes": wanted or list((flow.get("scopes") or {}).keys())}} |
There was a problem hiding this comment.
Please preserve an empty required OAuth scope list. wanted or list(flow.scopes) requests every advertised scope when no scopes are required. A flow advertising read and admin produces a suggestion requesting both. This broadens requested privileges and can fail for restricted clients. Apply the same correction to the authorization-code branch and test both flows.
There was a problem hiding this comment.
Fixed for both flows! scopes now holds only the scopes the requirement names, so an empty list stays empty. Tests cover clientCredentials and authorizationCode.
| - **Webhook HMAC replay protection** (internal issue 1149, PSIRT ADV0369535) - The `webhook_notification` plugin accepts `hmac_include_timestamp: true`. The signature then covers `{timestamp}.{delivery_id}.{body}`, and the plugin sends `X-Timestamp` and `X-Delivery-Id` headers. Each retry is signed again with a fresh timestamp. The default stays `false`, so existing receivers keep verifying `HMAC(body)`. The default flips to `true` in a later minor release. | ||
|
|
||
| #### **API & Platform** | ||
|
|
There was a problem hiding this comment.
Please update the statement that every failure returns an identical response. Authentication failures return auth_required, and absent cards return no_card. Only blocked addresses and network failures share the identical unreachable response.
There was a problem hiding this comment.
Fixed! The entry now lists no_card, auth_required and unreachable. It says only a blocked address and a network failure share the identical unreachable response.
msureshkumar88
left a comment
There was a problem hiding this comment.
Thank you for this slice of #4734. The route is backend only, adds no migration, and breaks no consumer contract. The security base is strong: redirects off, DNS pinning, forced Host, 1 MiB cap, a validated WWW-Authenticate scheme token, a2a.create, and the token-scope entry. The round-trip test and the deny-path tests are welcome. I ran the PR tests locally and they pass (99% line coverage on a2a_card_import.py). One finding blocks merge.
severity | path:line | problem | fix
blocking | mcpgateway/services/a2a_card_import.py:253-319, 146-186 | A malformed card returns HTTP 500. normalize_card and suggest_agent_fields assume every nested value has the spec type, and fetch_agent_card calls them outside any try. I reproduced AttributeError for: securityRequirements: ["x"]; schemes as a list; securitySchemes as a list or with a string value; an OAuth flow as a string; OAuth scopes as a list; capabilities as a list (1.0 and 0.3); 0.3 securitySchemes as a list; 0.3 security: ["x"]. The host the caller names controls the card, so the crash is reachable. | Check isinstance(..., dict/list) at each use, or guard normalize and suggest and return no_card. Add parametrized malformed-card tests.
functionally-impacting | a2a_card_import.py:473-486 | The pre-1.0 path is tried only after a 404. I verified that a 200 HTML body, 301, 405 and 500 on the 1.0 path all return no_card without trying agent.json. #4734 says both paths are tried before reporting none. | Move to the next candidate on any result except 401/403, unreachable and found. Update test_no_fallback_when_1_0_path_returns_non_card.
functionally-impacting | a2a_card_import.py:80-89 | A base_url with a query or fragment builds a wrong URL: https://h/?a=b becomes https://h/?a=b/.well-known/agent-card.json. The PR text says query and fragment are rejected, but only path is checked. | Reject query and fragment on base_url in the schema, or build candidates with urlsplit. Keep a query only for a direct .json URL.
functionally-impacting | a2a_card_import.py:83, 470-471 | %2e%2e and %2E%2E pass the path check. A rejected path also returns no_card with HTTP 200, which tells the client to offer manual entry for what is a client input error. | Validate path in A2AFetchCardRequest so a bad path returns 422. Reject % or decode before the segment check.
functionally-impacting | a2a_card_import.py:371-388 | #4734 says "unreachable, or address refused: stop, name which". The PR merges both into unreachable. The goal is sound, but the claim that the route cannot reveal which hosts it reaches is too strong: a blocked host answers at once, and a down host waits for federation_timeout. | Record the decision in #4734 with the issue owner. Then remove the claim from the PR, docstring and CHANGELOG, or add a constant response-time floor.
functionally-impacting | PR scope | AGENTS.md requires a black-box test against a live gateway for behavior reachable through it. None is added. TestRouteAuthorization calls the handler directly and skips the middleware and RBAC stack. | Add tests/live_gateway/a2a/test_a2a_fetch_card_e2e.py: 1.0 card, 0.3 card, 404, 401, unreachable, blocked host, /v1 alias, token with only a2a.read or a2a.invoke gets 403, no token gets 401.
functionally-impacting | CHANGELOG.md:44 | The entry says the route "returns one identical found: false response for every failure". Commit fa1ad50db changed this to three outcomes. | Name no_card, auth_required and unreachable.
functionally-impacting | docs/docs/using/agents/a2a.md, docs/docs/manage/rbac.md | No documentation for the new route. | Add the request and response shape, the outcomes table, why a2a.create gates it, and the SSRF settings that apply.
suggestion | a2a_card_import.py:416-451 | timeout=federation_timeout applies per socket operation, not in total (CWE-400). A slow server holds a worker. | Wrap _get_json in asyncio.timeout(...).
suggestion | mcpgateway/main.py:5184 | No per-user rate limit and no audit entry. The no_card and unreachable split is an open-port oracle for hosts the policy allows (CWE-204). | Apply the existing rate limit to the route. Log user, host and outcome per fetch.
suggestion | a2a_card_import.py:302-318 | tokenUrl and authorizationUrl come from an untrusted card and flow into suggested_agent.oauth_config. A user who then enters a client secret sends it to that host (CWE-441). | Validate both URLs with the outbound policy. Add a warning when the host differs from the agent host. #7025 must show both URLs.
suggestion | schemas.py validate_headers | The validator blocks Host, CR, LF and NUL. It still allows Content-Length, Transfer-Encoding, Connection, Upgrade and Proxy-*, and sets no limit on header count or size. Authorization can also go over plain http:// (CWE-319). | Add a deny list and a size cap. Warn when headers go over http to a non-loopback host.
suggestion | a2a_card_import.py:232-234, 485 | For a 1.0 card, card and rawCard are the same document. The response carries up to 2 MiB. | Return rawCard only when it differs from card.
minor | a2a_card_import.py:36-38, 371-400; schemas.py | reason and its three constants repeat outcome. found repeats outcome == "found". not_found, unreachable and auth_required are public but used only in this module. detected_version and suggested_agent are loose types. | Drop reason, make the helpers private, use Literal["1.0","0.3"].
minor | a2a_card_import.py:104; mcpgateway/main.py imports | Line 104 is the only uncovered line. The new imports in main.py are not in alphabetical order. | Add a test for a scheme with no *SecurityScheme key. Sort the imports.
minor | PR body | The body says the extended card "needs its own route", yet this route accepts path and headers, which already serve an extended card exposed over GET. | State the scope in the PR body and in #4734.
Additional test gaps: a slow-drip server, base_url with userinfo, and a 0.3 card with several schemes and flows mapped into a real A2AAgentCreate.
I am glad to re-review after the blocking item and the fallback fix.
Address review feedback on #7377. - Read raw bytes with identity encoding. Discard content-encoded responses. - End each fetch after FEDERATION_TIMEOUT in total. - Validate nested card fields. Return no_card for a malformed card. - Try the pre-1.0 path after any answer that is not a card, 401, 403 or failure. - Treat schemes in one security requirement as a group. Warn when one cannot be mapped. - Convert skill-level security in legacy cards. - Keep an empty OAuth scope list empty. - Warn when an OAuth URL points to a host other than the agent host. - Return 422 for an unsafe path, or a baseUrl with userinfo, query or fragment. - Reject framing headers. Cap header count and size. - Omit rawCard when it equals card. - Pass the FastAPI Request to the RBAC check. - Add a live-gateway test and documentation for the route. Signed-off-by: Marek Dano <mk.dano@gmail.com>
b87d0be to
3abfed3
Compare
|
@msureshkumar88 - thanks for reviewing!
|
There was a problem hiding this comment.
🟡 Changes recommended
Malformed cards can produce incorrect capability and OAuth settings, skill-level authentication requirements go unflagged, and discovery can exceed the configured total timeout.
3 open issues
What changed in this PR
Adds Agent Card discovery before registration, supporting the import flow in #4734 and the UI work in #7025 without storing cards.
Changes:
- Adds
/a2a/fetch-cardand/v1/a2a/fetch-card, protected bya2a.create. - Fetches cards with outbound safeguards, normalizes legacy cards, and suggests registration fields with explicit outcome codes.
- Adds unit and live-gateway coverage, API documentation, and release notes.
| File | Description |
|---|---|
tests/unit/mcpgateway/services/test_a2a_card_import.py |
Tests fetching, normalization, mapping, validation, and authorization. |
tests/unit/mcpgateway/middleware/test_token_scoping.py |
Checks fetch-card permission mapping and denied scopes. |
tests/live_gateway/a2a/test_a2a_fetch_card_e2e.py |
Exercises discovery and deny paths through a running gateway. |
mcpgateway/services/a2a_card_import.py |
Implements guarded fetching, normalization, and field mapping. |
mcpgateway/schemas.py |
Defines request validation and response fields. |
mcpgateway/middleware/token_scoping.py |
Maps fetch-card requests to a2a.create. |
mcpgateway/main.py |
Registers the authenticated fetch-card handler. |
docs/docs/using/agents/a2a.md |
Documents discovery, outcomes, mapping, and safeguards. |
docs/docs/manage/rbac.md |
Explains the required permission. |
CHANGELOG.md |
Announces the discovery endpoint. |
.secrets.baseline |
Refreshes scan metadata and tracked line numbers. |
Review effort: Balanced
| if not _is_well_formed(document): | ||
| return None |
There was a problem hiding this comment.
Feedback from LLM review of Copilot's suggestions: not blocking.
- streaming: "false" is read as true, and a scope of {"list": "read"} becomes "r", "e", "a", "d". Both cards pass with no warning.
- The docs say cards with wrong value types are rejected, but the code doesn't check values. The scope case matters most, since bad scopes get saved if accepted. Fix the check or reword the docs.
- Not blocking: the route is new, the user reviews before saving, and settings are rechecked at registration.
| schemes = card.get("securitySchemes") or {} | ||
| warnings: List[str] = [] | ||
| chosen: Dict[str, Any] = {} | ||
| for requirement in card.get("securityRequirements") or []: |
There was a problem hiding this comment.
Feedback from LLM review of Copilot's suggestions: not blocking.
- Confirmed for both card versions. If only individual skills require login, no auth is suggested and no warning appears.
- The suggestion only reads the card's top-level security. Per-skill auth can't be saved anyway, so this should at least warn. Otherwise the agent is saved with no auth and calls to those skills fail.
| for url in build_candidate_urls(base_url, path): | ||
| status, document, challenge = await _get_json(url, headers) | ||
| if status is None: | ||
| return _unreachable() | ||
| if status in (401, 403): | ||
| return _auth_required(challenge) | ||
| if document is None or not is_agent_card(document): | ||
| continue | ||
| found = _build_found_response(url, document) | ||
| if found: | ||
| return found | ||
| return _not_found() |
There was a problem hiding this comment.
Feedback from LLM review of Copilot's suggestions: not blocking.
- A single slow request doesn't cause this: if the first address times out, the search stops there. The total only runs over when the first address answers just before its limit and the second one hangs. The bigger gap is that the DNS lookup happens before the timer starts, so it isn't limited at all.
- The proposed fix is right: put one timer around the whole fetch, DNS lookup included.
a-effort
left a comment
There was a problem hiding this comment.
Ran Copilot review and had a LLM review/verify. 🤖🤖
No blockers, additional feedback:
OAuth warning: the check compares hostnames only, so it misses an http token address. Registration catches this, but add a warning for non-https addresses.
…stration Closes part of #4734 (slice 1: fetch route). Add a route that fetches an A2A Agent Card from an unregistered endpoint. The route accepts baseUrl, an optional path, and optional headers. - Try /.well-known/agent-card.json first. Fall back to /.well-known/agent.json only on a 404. - Normalize a 0.3 card to the 1.0 shape. Return the normalized card, the card as fetched, and a suggested mapping onto A2AAgentCreate fields. The mapping never contains credentials, team, visibility, or tags. - Require a2a.create, because the gateway sends a request to a URL the caller chose. Add the route to the token scoping map. - Apply the outbound URL policy and DNS pinning. Do not follow redirects. Cap the response body at 1 MiB. - Return one identical found:false response for every failure, so the route cannot reveal which hosts the gateway can reach. Signed-off-by: Marek Dano <mk.dano@gmail.com>
Signed-off-by: Marek Dano <mk.dano@gmail.com>
Add tests for 0.3 security scheme conversion, additional interfaces, auth field mapping, _get_json outcomes, and the fetch-card route. Raise changed-line coverage above the diff-cover threshold. Signed-off-by: Marek Dano <mk.dano@gmail.com>
Add an `outcome` field to the fetch-card response: `found`, `no_card`, `auth_required` or `unreachable`. The client branches on `outcome` instead of matching an error string. A 401 or 403 now returns `auth_required` with the `WWW-Authenticate` scheme name in `auth_scheme`. The UI can collect a credential and retry the same fetch. The search stops at the first 401 or 403. A blocked address and a network failure both return `unreachable` with the same body. The route cannot probe which hosts the gateway can reach. `_get_json` now returns the `WWW-Authenticate` value with the status. Tests cover each outcome, scheme validation and the identical `unreachable` body. Signed-off-by: Marek Dano <mk.dano@gmail.com>
Address review feedback on #7377. - Read raw bytes with identity encoding. Discard content-encoded responses. - End each fetch after FEDERATION_TIMEOUT in total. - Validate nested card fields. Return no_card for a malformed card. - Try the pre-1.0 path after any answer that is not a card, 401, 403 or failure. - Treat schemes in one security requirement as a group. Warn when one cannot be mapped. - Convert skill-level security in legacy cards. - Keep an empty OAuth scope list empty. - Warn when an OAuth URL points to a host other than the agent host. - Return 422 for an unsafe path, or a baseUrl with userinfo, query or fragment. - Reject framing headers. Cap header count and size. - Omit rawCard when it equals card. - Pass the FastAPI Request to the RBAC check. - Add a live-gateway test and documentation for the route. Signed-off-by: Marek Dano <mk.dano@gmail.com>
…total fetch time Signed-off-by: Marek Dano <mk.dano@gmail.com>
3abfed3 to
cb8313d
Compare


Summary
Slice 1 of #4734. Adds
POST /a2a/fetch-card(also/v1/a2a/fetch-card). The route fetches an A2A Agent Card from an endpoint that is not registered yet. This unblocks the card import flow in #7025.Refs #4734
What changed
baseUrl, an optionalpath, and optionalheaders./.well-known/agent-card.json. It tries/.well-known/agent.jsononly after a 404. A URL that names a.jsondocument is used as given.foundandoutcome.outcomeisfound,no_card,auth_requiredorunreachable. The client branches onoutcome, never on an error string.authScheme: theWWW-Authenticatescheme name. Set only forauth_required.card: the card normalized to the A2A 1.0 shape. A 0.3 card is converted.rawCard: the card as fetched.detectedVersion:1.0or0.3.suggestedAgent: a mapping ontoA2AAgentCreatefields.warnings: card content that ContextForge cannot map.suggestedAgentnever contains credential values, team, visibility, or tags. When a card declares several security schemes, the first supported one is chosen. The others are listed inwarnings.mcpgateway/services/a2a_card_import.py. A round-trip test builds a card with_build_v1_agent_cardand maps it back to the same agent.Failure outcomes
outcomeno_cardauth_requiredauthSchemecarries theWWW-Authenticatescheme name.unreachableA 401 or 403 stops the search. The route does not try the legacy path.
Security
a2a.create, because the gateway sends a request to a URL the caller chose.a2a.createpass. Tokens with onlya2a.readora2a.invokeare denied.unreachablebody. The route cannot reveal which hosts the gateway can reach. The response still separates "host answered" from "host did not answer"..., a query, a fragment, or whitespace is rejected. Header names and values are validated. AHostoverride is rejected.authSchemeholds only a validated scheme token. The route never echoesrealmor other challenge parameters.unreachablebody for blocked and failed hosts.Not in this PR
agent_cardtable and migration).headersfield only reaches the card URL on the same host. The extended card sits at a different path, so the "fetch extended card with credential" step needs its own route.icon_urlandtenantinsuggestedAgent.A2AAgentCreatehas no such fields. They remain incard.Manual test against a running gateway
Start the gateway with
make dev. The test serves cards fromlocalhost, so setSSRF_ALLOW_LOCALHOST=trueandSSRF_ALLOW_PRIVATE_NETWORKS=truein.envfirst. Create tokens:Serve a 1.0 card and a 0.3 card from a local directory:
Run each request:
Expected results:
foundtrue,outcomefound,detectedVersion1.0,card,rawCard,suggestedAgent,warnings.suggestedAgenthas no credential values,team_id,visibility, ortags.detectedVersion0.3,cardin the 1.0 shape,rawCardunchangedfoundtruefoundfalse,outcomeno_cardoutcomeauth_required,authSchemeBearer, norealmin the bodyoutcomeunreachableoutcomeunreachable, same body as case 6Hostheader overridea2a.readonlya2a.invokeonly/v1aliasThe route also appears in
/openapi.jsonas/a2a/fetch-card.Results from manual testing