Skip to content

datarobot_genai.dragent.frontends.a2a

a2a

A2A server helpers for DataRobot-hosted agents.

This module owns the A2A protocol layer: agent card construction, OAuth2 security scheme assembly, Cross-Application Access capability extensions, and endpoint URL resolution. The FastAPI framework glue lives in :mod:~datarobot_genai.dragent.frontends.fastapi.

get_a2a_endpoint_url

get_a2a_endpoint_url(host: str, port: int) -> str

Construct the A2A endpoint URL for the running server.

In a DataRobot deployment (MLOPS_DEPLOYMENT_ID is set), uses the deployment's direct-access URL built from DATAROBOT_PUBLIC_API_ENDPOINT / DATAROBOT_ENDPOINT. Otherwise falls back to the local http://{host}:{port}/a2a/ URL.

Source code in datarobot_genai/dragent/frontends/a2a.py
def get_a2a_endpoint_url(host: str, port: int) -> str:
    """Construct the A2A endpoint URL for the running server.

    In a DataRobot deployment (``MLOPS_DEPLOYMENT_ID`` is set), uses the
    deployment's direct-access URL built from ``DATAROBOT_PUBLIC_API_ENDPOINT``
    / ``DATAROBOT_ENDPOINT``.  Otherwise falls back to the local
    ``http://{host}:{port}/a2a/`` URL.
    """
    deployment_id = get_deployment_id()
    workload_id = get_workload_id()

    if not (deployment_id or workload_id):
        return f"http://{host}:{port}/{A2A_MOUNT_PATH}/"

    datarobot_endpoint = resolve_datarobot_endpoint(require=True)
    assert datarobot_endpoint is not None  # guaranteed by require=True

    if deployment_id:
        return build_deployment_a2a_url(datarobot_endpoint, deployment_id)
    assert workload_id is not None  # non-None guaranteed by the early-return above
    return build_workload_a2a_url(datarobot_endpoint, workload_id)

resolve_oauth_endpoints async

resolve_oauth_endpoints(server_auth_config: OAuth2ResourceServerConfig) -> tuple[str, str]

Resolve (authorization_url, token_url) from an OAuth2ResourceServerConfig.

Uses OIDC discovery when discovery_url is set, otherwise derives from issuer_url.

Source code in datarobot_genai/dragent/frontends/a2a.py
async def resolve_oauth_endpoints(
    server_auth_config: OAuth2ResourceServerConfig,
) -> tuple[str, str]:
    """Resolve ``(authorization_url, token_url)`` from an OAuth2ResourceServerConfig.

    Uses OIDC discovery when ``discovery_url`` is set, otherwise derives from ``issuer_url``.
    """
    if server_auth_config.discovery_url:
        try:
            async with httpx.AsyncClient() as client:
                response = await client.get(server_auth_config.discovery_url, timeout=5.0)
                response.raise_for_status()
                metadata = response.json()
                auth_url = metadata.get("authorization_endpoint")
                token_url = metadata.get("token_endpoint")
                if auth_url and token_url:
                    logger.info(
                        "Resolved OAuth endpoints via discovery: %s",
                        server_auth_config.discovery_url,
                    )
                    return auth_url, token_url
        except Exception as e:
            logger.warning("Failed to discover OAuth endpoints: %s", e)

    issuer = server_auth_config.issuer_url.rstrip("/")
    auth_url = f"{issuer}/oauth/authorize"
    token_url = f"{issuer}/oauth/token"
    logger.info("Using derived OAuth endpoints from issuer: %s", issuer)
    return auth_url, token_url

build_oauth_flow_from_server_auth async

build_oauth_flow_from_server_auth(server_auth: OAuth2ResourceServerConfig) -> tuple[AuthorizationCodeOAuthFlow, list[str]]

Build the authorization_code OAuth2 flow and scopes from a NAT server_auth config.

Source code in datarobot_genai/dragent/frontends/a2a.py
async def build_oauth_flow_from_server_auth(
    server_auth: OAuth2ResourceServerConfig,
) -> tuple[AuthorizationCodeOAuthFlow, list[str]]:
    """Build the authorization_code OAuth2 flow and scopes from a NAT server_auth config."""
    auth_url, token_url = await resolve_oauth_endpoints(server_auth)
    flow = AuthorizationCodeOAuthFlow(
        authorization_url=auth_url,
        token_url=token_url,
        scopes={scope: f"Permission: {scope}" for scope in server_auth.scopes},
    )
    return flow, list(server_auth.scopes)

build_oauth_flow_from_cross_app_access

build_oauth_flow_from_cross_app_access(config: CrossApplicationAccessConfig) -> tuple[ClientCredentialsOAuthFlow, list[str]]

Build the client_credentials flow and scopes from a CrossApplicationAccessConfig.

Extracts the OpenAPI-standard fields (token_url, scopes) only. Cross-Application Access extension parameters are handled separately by :func:build_cross_app_capability_extension and MUST NOT appear here.

Source code in datarobot_genai/dragent/frontends/a2a.py
def build_oauth_flow_from_cross_app_access(
    config: CrossApplicationAccessConfig,
) -> tuple[ClientCredentialsOAuthFlow, list[str]]:
    """Build the client_credentials flow and scopes from a CrossApplicationAccessConfig.

    Extracts the OpenAPI-standard fields (``token_url``, ``scopes``) only.
    Cross-Application Access extension parameters are handled separately by
    :func:`build_cross_app_capability_extension` and MUST NOT appear here.
    """
    flow = ClientCredentialsOAuthFlow(
        token_url=config.token_request.token_url,
        scopes={scope: f"Permission: {scope}" for scope in config.token_request.scopes},
    )
    return flow, list(config.token_request.scopes)

build_cross_app_capability_extension

build_cross_app_capability_extension(config: CrossApplicationAccessConfig) -> list[AgentExtension]

Build the Cross-Application Access extension entry for capabilities.extensions.

Only extension-bound fields go in params; token_url and scopes are intentionally omitted — they belong to OpenAPI securitySchemes.

Source code in datarobot_genai/dragent/frontends/a2a.py
def build_cross_app_capability_extension(
    config: CrossApplicationAccessConfig,
) -> list[AgentExtension]:
    """Build the Cross-Application Access extension entry for ``capabilities.extensions``.

    Only extension-bound fields go in ``params``; ``token_url`` and ``scopes``
    are intentionally omitted — they belong to OpenAPI ``securitySchemes``.
    """
    params: dict = {
        "ref": {
            "scheme": CROSS_APP_SECURITY_SCHEME_REF,
            "flow": CROSS_APP_SECURITY_SCHEME_FLOW_REF,
        },
        "tokenEndpointAuthMethod": config.token_endpoint_auth_method,
        "tokenExchange": {
            "grantType": TOKEN_EXCHANGE_GRANT_TYPE_URI,
            "requestedTokenType": TOKEN_EXCHANGE_REQUESTED_TOKEN_TYPE,
            "trustedIssuer": config.token_exchange.trusted_issuer,
            "audience": config.token_exchange.audience,
        },
        "tokenRequest": {
            "grantType": JWT_BEARER_GRANT_TYPE_URI,
            "audience": config.token_request.audience,
        },
    }
    return [
        AgentExtension(
            uri=JWT_BEARER_GRANT_TYPE_URI,
            description=CROSS_APP_EXTENSION_DESCRIPTION,
            params=params,
        )
    ]

build_internal_identity_extension

build_internal_identity_extension() -> AgentExtension | None

Build the internal identity extension for the current runtime, or None in local dev.

In a deployment container (MLOPS_DEPLOYMENT_ID) the params carry deployment_id; in a workload container (WORKLOAD_ID) they carry workload_id. Returns None when neither identity is present.

Source code in datarobot_genai/dragent/frontends/a2a.py
def build_internal_identity_extension() -> AgentExtension | None:
    """Build the internal identity extension for the current runtime, or None in local dev.

    In a deployment container (``MLOPS_DEPLOYMENT_ID``) the params carry
    ``deployment_id``; in a workload container (``WORKLOAD_ID``) they carry
    ``workload_id``.  Returns *None* when neither identity is present.
    """
    if dep_id := get_deployment_id():
        params = {"deployment_id": dep_id}
    elif wl_id := get_workload_id():
        params = {"workload_id": wl_id}
    else:
        return None

    return AgentExtension(
        uri=INTERNAL_IDENTITY_URI,
        description=INTERNAL_IDENTITY_DESCRIPTION,
        required=True,
        params=params,
    )

build_external_identity_extension

build_external_identity_extension(external_id: str) -> AgentExtension

Build the external identity extension for catalog discovery.

Source code in datarobot_genai/dragent/frontends/a2a.py
def build_external_identity_extension(external_id: str) -> AgentExtension:
    """Build the external identity extension for catalog discovery."""
    return AgentExtension(
        uri=EXTERNAL_IDENTITY_URI,
        description=EXTERNAL_IDENTITY_DESCRIPTION,
        required=False,
        params={"id": external_id},
    )

build_security_schemes async

build_security_schemes(frontend_config: A2AFrontEndConfig, cross_app_access: CrossApplicationAccessConfig | None) -> tuple[dict[str, SecurityScheme] | None, list[dict[str, list[str]]] | None]

Assemble A2A security schemes, merging up to two auth sources.

  • server_auth → authorization_code flow.
  • cross_app_access → client_credentials flow.

Returns (security_schemes, security_requirements), both None when neither source is configured.

Source code in datarobot_genai/dragent/frontends/a2a.py
async def build_security_schemes(
    frontend_config: A2AFrontEndConfig,
    cross_app_access: CrossApplicationAccessConfig | None,
) -> tuple[
    dict[str, SecurityScheme] | None,
    list[dict[str, list[str]]] | None,
]:
    """Assemble A2A security schemes, merging up to two auth sources.

    * ``server_auth`` → authorization_code flow.
    * ``cross_app_access`` → client_credentials flow.

    Returns ``(security_schemes, security_requirements)``, both ``None``
    when neither source is configured.
    """
    server_auth = frontend_config.server_auth

    if not server_auth and not cross_app_access:
        return None, None

    auth_code_flow, server_auth_scopes = (
        await build_oauth_flow_from_server_auth(server_auth) if server_auth else (None, [])
    )
    client_creds_flow, cross_app_scopes = (
        build_oauth_flow_from_cross_app_access(cross_app_access) if cross_app_access else (None, [])
    )

    all_scopes = list(dict.fromkeys(server_auth_scopes + cross_app_scopes))
    security_schemes = {
        "oauth2": SecurityScheme(
            root=OAuth2SecurityScheme(
                type="oauth2",
                description=OAUTH2_SECURITY_DESCRIPTION_WITH_TOKEN_EXCHANGE,
                flows=OAuthFlows(
                    authorization_code=auth_code_flow,
                    client_credentials=client_creds_flow,
                ),
            )
        )
    }
    return security_schemes, [{"oauth2": all_scopes}]

create_agent_card async

create_agent_card(frontend_config: A2AFrontEndConfig, cross_app_access: CrossApplicationAccessConfig | None, skills: list[AgentSkill], external: DRAgentA2AExternalConfig | None = None) -> AgentCard

Build an :class:~a2a.types.AgentCard for a DataRobot-hosted A2A agent.

When skills is empty, a single default skill is generated from frontend_config.name / frontend_config.description.

Source code in datarobot_genai/dragent/frontends/a2a.py
async def create_agent_card(
    frontend_config: A2AFrontEndConfig,
    cross_app_access: CrossApplicationAccessConfig | None,
    skills: list[AgentSkill],
    external: DRAgentA2AExternalConfig | None = None,
) -> AgentCard:
    """Build an :class:`~a2a.types.AgentCard` for a DataRobot-hosted A2A agent.

    When ``skills`` is empty, a single default skill is generated from
    ``frontend_config.name`` / ``frontend_config.description``.
    """
    security_schemes, security = await build_security_schemes(frontend_config, cross_app_access)
    extensions = _collect_extensions(cross_app_access, external)

    resolved_skills = skills or [
        AgentSkill(
            id="call",
            name=frontend_config.name,
            description=frontend_config.description,
            tags=[],
            examples=[],
        )
    ]

    url = _resolve_url(frontend_config, external)

    return AgentCard(
        name=frontend_config.name,
        description=frontend_config.description,
        url=url,
        version=frontend_config.version,
        default_input_modes=frontend_config.default_input_modes,
        default_output_modes=frontend_config.default_output_modes,
        capabilities=AgentCapabilities(
            streaming=frontend_config.capabilities.streaming,
            push_notifications=frontend_config.capabilities.push_notifications,
            extensions=extensions,
        ),
        skills=resolved_skills,
        security_schemes=security_schemes or None,
        security=security or None,
    )