Skip to content

feat(a2a): add POST /a2a/fetch-card to read an Agent Card before registration - #7377

Open
marekdano wants to merge 6 commits into
mainfrom
4734-a2a-fetch-card
Open

marekdano wants to merge 6 commits into
mainfrom
4734-a2a-fetch-card

Conversation

@marekdano

@marekdano marekdano commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • The route accepts baseUrl, an optional path, and optional headers.
  • With only a base URL, the route tries /.well-known/agent-card.json. It tries /.well-known/agent.json only after a 404. A URL that names a .json document is used as given.
  • The response contains:
    • found and outcome. outcome is found, no_card, auth_required or unreachable. The client branches on outcome, never on an error string.
    • authScheme: the WWW-Authenticate scheme name. Set only for auth_required.
    • card: the card normalized to the A2A 1.0 shape. A 0.3 card is converted.
    • rawCard: the card as fetched.
    • detectedVersion: 1.0 or 0.3.
    • suggestedAgent: a mapping onto A2AAgentCreate fields.
    • warnings: card content that ContextForge cannot map.
  • suggestedAgent never contains credential values, team, visibility, or tags. When a card declares several security schemes, the first supported one is chosen. The others are listed in warnings.
  • The mapping sits in mcpgateway/services/a2a_card_import.py. A round-trip test builds a card with _build_v1_agent_card and maps it back to the same agent.

Failure outcomes

outcome Cause Client action
no_card The host answered but served no card: 404 on both paths, another non-200 status, a non-JSON body, or a non-card document. Offer manual entry.
auth_required The host answered 401 or 403. authScheme carries the WWW-Authenticate scheme name. Collect a credential and retry the same fetch.
unreachable Connection failure, or a URL refused by the outbound policy. Report that the agent cannot be reached.

A 401 or 403 stops the search. The route does not try the legacy path.

Security

  • The route requires a2a.create, because the gateway sends a request to a URL the caller chose.
  • The route is in the token scoping map. Scoped tokens with a2a.create pass. Tokens with only a2a.read or a2a.invoke are denied.
  • The fetch applies the outbound URL policy and DNS pinning. It does not follow redirects. It caps the body at 1 MiB.
  • A refused address and a network failure return the same unreachable body. The route cannot reveal which hosts the gateway can reach. The response still separates "host answered" from "host did not answer".
  • A path with .., a query, a fragment, or whitespace is rejected. Header names and values are validated. A Host override is rejected.
  • authScheme holds only a validated scheme token. The route never echoes realm or other challenge parameters.
  • Deny-path tests cover a missing permission, a missing user, the token scope, and the identical unreachable body for blocked and failed hosts.

Not in this PR

  • Storing the card (agent_card table and migration).
  • Re-sync and drift reporting.
  • Fetching the extended card with credentials. The headers field 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_url and tenant in suggestedAgent. A2AAgentCreate has no such fields. They remain in card.
  • Making the card generator and the importer read one shared field table. The round-trip test pins them together for now.

Manual test against a running gateway

Start the gateway with make dev. The test serves cards from localhost, so set SSRF_ALLOW_LOCALHOST=true and SSRF_ALLOW_PRIVATE_NETWORKS=true in .env first. Create tokens:

mint() { python -m mcpgateway.utils.create_jwt_token --secret "$JWT_SECRET_KEY" --exp 10080 "$@"; }
export TOKEN=$(mint --username admin@example.com --admin)
export READ_TOKEN=$(mint --username admin@example.com --scopes '{"permissions":["a2a.read"]}')
export INVOKE_TOKEN=$(mint --username admin@example.com --scopes '{"permissions":["a2a.invoke"]}')

Serve a 1.0 card and a 0.3 card from a local directory:

mkdir -p /tmp/cards/.well-known /tmp/cards/legacy/.well-known
# Write a 1.0 card to /tmp/cards/.well-known/agent-card.json
# Write a 0.3 card to /tmp/cards/legacy/.well-known/agent.json
(cd /tmp/cards && python -m http.server 9998) &

Run each request:

# 1. 1.0 card, base URL only
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998"}'

# 2. 0.3 card is converted
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998/legacy"}'

# 3. Direct card URL
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998/.well-known/agent-card.json"}'

# 4. Nothing at the URL
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998/missing"}'

# 5. Agent needs a key
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9997"}'

# 6. Unreachable host
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:1"}'

# 7. Blocked host
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://169.254.169.254"}'

# 8. Unsafe path
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998","path":"../etc/passwd"}'

# 9. Host header override
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998","headers":{"Host":"evil.example"}}'

# 10. Token with a2a.read only
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $READ_TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998"}'

# 11. Token with a2a.invoke only
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Authorization: Bearer $INVOKE_TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998"}'

# 12. No token
curl -s -w '\n[%{http_code}]\n' -X POST $URL -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998"}'

# 13. /v1 alias
curl -s -w '\n[%{http_code}]\n' -X POST localhost:8000/v1/a2a/fetch-card -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"baseUrl":"http://localhost:9998"}'

Expected results:

# Case Expected
1 1.0 card, base URL only 200, found true, outcome found, detectedVersion 1.0, card, rawCard, suggestedAgent, warnings. suggestedAgent has no credential values, team_id, visibility, or tags.
2 0.3 card is converted 200, detectedVersion 0.3, card in the 1.0 shape, rawCard unchanged
3 Direct card URL 200, found true
4 Nothing at the URL 200, found false, outcome no_card
5 Agent needs a key 200, outcome auth_required, authScheme Bearer, no realm in the body
6 Unreachable host 200, outcome unreachable
7 Blocked host 200, outcome unreachable, same body as case 6
8 Unsafe path 422
9 Host header override 422
10 Token with a2a.read only 403
11 Token with a2a.invoke only 403
12 No token 401
13 /v1 alias Same result as case 1

The route also appears in /openapi.json as /a2a/fetch-card.

Results from manual testing

Screenshot 2026-10-30 at 14 29 08 Screenshot 2026-10-30 at 14 29 23

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

Comments on card fetching, normalization, and authentication mapping.

Comment thread mcpgateway/services/a2a_card_import.py Outdated
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > MAX_CARD_BYTES:

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread mcpgateway/main.py Outdated
@a2a_router.post("/fetch-card", response_model=A2AFetchCardResponse)
@require_permission("a2a.create")
async def fetch_a2a_agent_card(
request: A2AFetchCardRequest,

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread mcpgateway/services/a2a_card_import.py Outdated
continue
if document is None or not is_agent_card(document):
return not_found()
card, version = normalize_card(document)

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread mcpgateway/services/a2a_card_import.py Outdated
warnings: List[str] = []
chosen: Dict[str, Any] = {}
for requirement in card.get("securityRequirements") or []:
for name, scopes in (requirement.get("schemes") or {}).items():

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread mcpgateway/services/a2a_card_import.py Outdated
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

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread mcpgateway/services/a2a_card_import.py Outdated
try:
candidates = build_candidate_urls(base_url, path)
except CardPathError:
return not_found()

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread mcpgateway/services/a2a_card_import.py Outdated
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())}}

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed for both flows! scopes now holds only the scopes the requirement names, so an empty list stays empty. Tests cover clientCredentials and authorizationCode.

Comment thread CHANGELOG.md
- **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**

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

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.

marekdano added a commit that referenced this pull request Nov 5, 2026
  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>
@marekdano
marekdano force-pushed the 4734-a2a-fetch-card branch from b87d0be to 3abfed3 Compare November 5, 2026 15:57
@marekdano

Copy link
Copy Markdown
Collaborator Author

@msureshkumar88 - thanks for reviewing!

Severity Finding Status Response
blocking Malformed card returns HTTP 500 Fixed A validation step now runs before normalize_card and suggest_agent_fields. A malformed card returns no_card. It covers every case you listed. I added 20 parametrized malformed-card tests and a live-gateway test that checks for 200 with no_card, not 500.
functionally-impacting Pre-1.0 path tried only after a 404 Fixed The search moves to the next candidate on any answer except a card, a 401/403, or a failed connection. I replaced test_no_fallback_when_1_0_path_returns_non_card with a parametrized test for 200 non-card, 200 with no body, 301, 405 and 500.
functionally-impacting base_url with a query or fragment builds a wrong URL Fixed A2AFetchCardRequest rejects userinfo, a query and a fragment on baseUrl with 422. A direct .json URL with a query is also rejected.
functionally-impacting %2e%2e passes; rejected path returns no_card with 200 Fixed path is validated in the request schema and returns 422. The check rejects %. The live-gateway test checks 422 on both route aliases.
functionally-impacting No live-gateway test Fixed Added tests/live_gateway/a2a/test_a2a_fetch_card_e2e.py. It covers the 1.0 and 0.3 cards, 404, 401, unreachable, blocked host, /v1 alias, malformed card, unsafe paths (422), a token with only a2a.read or a2a.invoke (403), and no token (401).
functionally-impacting CHANGELOG says "one identical found: false response" Fixed The entry names no_card, auth_required and unreachable.
functionally-impacting No documentation for the route Fixed Added "Reading an Agent Card Before Registration" to docs/docs/using/agents/a2a.md (request fields, outcomes table, why a2a.create gates the route, SSRF settings). Added the route to the permissions table and a note in docs/docs/manage/rbac.md.
suggestion Timeout applies per socket operation (CWE-400) Fixed _get_json runs inside asyncio.timeout(federation_timeout). A slow-drip test covers it.
suggestion No rate limit and no audit entry (CWE-204) Partly done The route now logs user, host and outcome for each fetch. I did not add a per-user rate limit. That needs a decision on which limiter applies. Should I open a follow-up issue?
suggestion tokenUrl and authorizationUrl are untrusted (CWE-441) Partly done The mapping adds a warning when either URL points to a host other than the agent host. I did not run the outbound URL policy on both URLs. #7025 must show the URLs and the warning before a user enters a client secret.
suggestion validate_headers allows framing headers and has no size limit Partly done The validator now rejects Content-Length, Transfer-Encoding, Connection, Upgrade, Proxy-*, TE, Trailer, Keep-Alive, Expect and Accept-Encoding. It caps headers at 20 and 8KiB. No warning yet for Authorization over plain http to a non-loopback host. The docs tell callers to use HTTPS.
suggestion card and rawCard are identical for 1.0 Fixed rawCard is omitted when it is identical to card. A converted 0.3 card still returns rawCard.
minor reason, found, public helpers, loose types Partly done The helpers and reason constants are private. detectedVersion is Literal["1.0", "0.3"]. I kept reason and found, because clients already read them. suggestedAgent is still loosely typed. Say if you want reason and found removed.
minor Uncovered line 104; unsorted imports in main.py Fixed A test covers a scheme with no *SecurityScheme key. The imports in main.py are sorted.
test gap Slow-drip server, base_url with userinfo, 0.3 multi-scheme card mapped into A2AAgentCreate Partly done Added the slow-drip and userinfo tests. I did not add the multi-scheme A2AAgentCreate test. Should I add it?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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-card and /v1/a2a/fetch-card, protected by a2a.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

Comment on lines +613 to +614
if not _is_well_formed(document):
return None

@a-effort a-effort Nov 10, 2026 •

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.

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 []:

@a-effort a-effort Nov 10, 2026 •

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.

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.

Comment thread mcpgateway/services/a2a_card_import.py Outdated
Comment on lines +648 to +659
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()

@a-effort a-effort Nov 10, 2026 •

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.

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 a-effort 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.

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>

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.

5 participants