Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.18.4"
version = "0.19.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
3 changes: 3 additions & 0 deletions src/sap_cloud_sdk/agentgateway/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"""

from sap_cloud_sdk.agentgateway._models import MCPTool
from sap_cloud_sdk.agentgateway.config import ClientConfig
from sap_cloud_sdk.agentgateway.agw_client import create_client, AgentGatewayClient
from sap_cloud_sdk.agentgateway.exceptions import (
AgentGatewaySDKError,
Expand All @@ -65,6 +66,8 @@
"create_client",
# Client class
"AgentGatewayClient",
# Configuration
"ClientConfig",
# Data models
"MCPTool",
# Exceptions
Expand Down
27 changes: 17 additions & 10 deletions src/sap_cloud_sdk/agentgateway/_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@
# Default credential path for Kyma production deployments
_CREDENTIALS_DEFAULT_PATH = "/etc/ums/credentials/credentials"

# HTTP timeout for token requests and MCP server calls (seconds)
_HTTP_TIMEOUT = 30.0

# Resource URN for Agent Gateway token scope (hardcoded - production value)
_AGW_RESOURCE_URN = "urn:sap:identity:application:provider:name:agent-gateway"

Expand Down Expand Up @@ -213,6 +210,7 @@ def _create_ssl_context(certificate: str, private_key: str) -> ssl.SSLContext:
def _request_token_mtls(
credentials: CustomerCredentials,
grant_type: str,
timeout: float,
app_tid: str | None = None,
extra_data: dict | None = None,
) -> str:
Expand Down Expand Up @@ -255,7 +253,7 @@ def _request_token_mtls(
try:
with httpx.Client(
verify=ssl_context,
timeout=_HTTP_TIMEOUT,
timeout=timeout,
) as client:
response = client.post(
credentials.token_service_url,
Expand Down Expand Up @@ -293,6 +291,7 @@ def _request_token_mtls(

def get_system_token_mtls(
credentials: CustomerCredentials,
timeout: float,
app_tid: str | None = None,
) -> str:
"""Get system-scoped token using mTLS client credentials flow.
Expand All @@ -301,6 +300,7 @@ def get_system_token_mtls(

Args:
credentials: Customer credentials.
timeout: HTTP timeout in seconds.
app_tid: BTP Application Tenant ID of subscriber (optional).

Returns:
Expand All @@ -310,6 +310,7 @@ def get_system_token_mtls(
return _request_token_mtls(
credentials,
grant_type=_GRANT_TYPE_CLIENT_CREDENTIALS,
timeout=timeout,
app_tid=app_tid,
extra_data={"response_type": "token"},
)
Expand All @@ -318,6 +319,7 @@ def get_system_token_mtls(
def exchange_user_token(
credentials: CustomerCredentials,
user_token: str,
timeout: float,
app_tid: str | None = None,
) -> str:
"""Exchange user token for AGW-scoped token using jwt-bearer grant.
Expand All @@ -328,6 +330,7 @@ def exchange_user_token(
Args:
credentials: Customer credentials.
user_token: User's JWT token to exchange.
timeout: HTTP timeout in seconds.
app_tid: BTP Application Tenant ID of subscriber (optional).

Returns:
Expand All @@ -337,6 +340,7 @@ def exchange_user_token(
return _request_token_mtls(
credentials,
grant_type=_GRANT_TYPE_JWT_BEARER,
timeout=timeout,
app_tid=app_tid,
extra_data={
"assertion": user_token,
Expand Down Expand Up @@ -371,6 +375,7 @@ async def _list_server_tools(
url: str,
auth_token: str,
dependency: IntegrationDependency,
timeout: float,
) -> list[MCPTool]:
"""List tools from a single MCP server.

Expand All @@ -390,7 +395,7 @@ async def _list_server_tools(
"Authorization": f"Bearer {auth_token}",
"x-correlation-id": str(uuid.uuid4()),
},
timeout=_HTTP_TIMEOUT,
timeout=timeout,
) as http_client:
async with streamable_http_client(url, http_client=http_client) as (
read,
Expand Down Expand Up @@ -427,6 +432,7 @@ async def _list_server_tools(

async def get_mcp_tools_customer(
credentials: CustomerCredentials,
timeout: float,
app_tid: str | None = None,
) -> list[MCPTool]:
"""List all MCP tools from servers defined in credentials.
Expand Down Expand Up @@ -456,7 +462,7 @@ async def get_mcp_tools_customer(
# Get system token for discovery
loop = asyncio.get_running_loop()
system_token = await loop.run_in_executor(
None, get_system_token_mtls, credentials, app_tid
None, get_system_token_mtls, credentials, timeout, app_tid
)

tools: list[MCPTool] = []
Expand All @@ -471,7 +477,7 @@ async def get_mcp_tools_customer(
)

try:
server_tools = await _list_server_tools(url, system_token, dep)
server_tools = await _list_server_tools(url, system_token, dep, timeout)
tools.extend(server_tools)
logger.debug("Loaded %d tool(s) from %s", len(server_tools), dep.ord_id)
except Exception:
Expand All @@ -487,6 +493,7 @@ async def call_mcp_tool_customer(
credentials: CustomerCredentials,
tool: MCPTool,
user_token: str | None,
timeout: float,
app_tid: str | None = None,
**kwargs,
) -> str:
Expand All @@ -513,7 +520,7 @@ async def call_mcp_tool_customer(
if user_token:
# Exchange user token for AGW-scoped token (with principal propagation)
agw_token = await loop.run_in_executor(
None, exchange_user_token, credentials, user_token, app_tid
None, exchange_user_token, credentials, user_token, timeout, app_tid
)
else:
# TODO: IBD workaround - use system token when user_token is not available.
Expand All @@ -524,15 +531,15 @@ async def call_mcp_tool_customer(
"Principal propagation will NOT work."
)
agw_token = await loop.run_in_executor(
None, get_system_token_mtls, credentials, app_tid
None, get_system_token_mtls, credentials, timeout, app_tid
)

async with httpx.AsyncClient(
headers={
"Authorization": f"Bearer {agw_token}",
"x-correlation-id": str(uuid.uuid4()),
},
timeout=_HTTP_TIMEOUT,
timeout=timeout,
) as http_client:
async with streamable_http_client(tool.url, http_client=http_client) as (
read,
Expand Down
15 changes: 8 additions & 7 deletions src/sap_cloud_sdk/agentgateway/_lob.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@

_DESTINATION_INSTANCE = "default"

# HTTP timeout for MCP server requests (seconds)
_HTTP_TIMEOUT = 30.0


def _ias_dest_name() -> str:
"""Get IAS destination name based on landscape.
Expand Down Expand Up @@ -227,7 +224,7 @@ def _fetch_user_auth_sync():


async def list_server_tools(
dest_url: str, system_auth: str, fragment_name: str
dest_url: str, system_auth: str, fragment_name: str, timeout: float
) -> list[MCPTool]:
"""List tools from a single MCP server.

Expand All @@ -241,7 +238,7 @@ async def list_server_tools(
"""
async with httpx.AsyncClient(
headers={"Authorization": system_auth, "x-correlation-id": str(uuid.uuid4())},
timeout=_HTTP_TIMEOUT,
timeout=timeout,
) as http_client:
async with streamable_http_client(dest_url, http_client=http_client) as (
read,
Expand Down Expand Up @@ -273,6 +270,7 @@ async def list_server_tools(

async def get_mcp_tools_lob(
tenant_subdomain: str,
timeout: float,
) -> list[MCPTool]:
"""List all MCP tools using LoB flow (destination-based).

Expand Down Expand Up @@ -309,7 +307,9 @@ async def get_mcp_tools_lob(

try:
system_auth = await get_system_auth(tenant_subdomain)
server_tools = await list_server_tools(mcp_url, system_auth, fragment_name)
server_tools = await list_server_tools(
mcp_url, system_auth, fragment_name, timeout
)
tools.extend(server_tools)
logger.debug(
"Loaded %d tool(s) from fragment '%s'",
Expand All @@ -330,6 +330,7 @@ async def call_mcp_tool_lob(
tool: MCPTool,
user_token: str,
tenant_subdomain: str,
timeout: float,
**kwargs,
) -> str:
"""Invoke an MCP tool using LoB flow (destination-based).
Expand Down Expand Up @@ -357,7 +358,7 @@ async def call_mcp_tool_lob(

async with httpx.AsyncClient(
headers={"Authorization": user_auth, "x-correlation-id": str(uuid.uuid4())},
timeout=_HTTP_TIMEOUT,
timeout=timeout,
) as http_client:
async with streamable_http_client(tool.url, http_client=http_client) as (
read,
Expand Down
38 changes: 31 additions & 7 deletions src/sap_cloud_sdk/agentgateway/agw_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Callable

from sap_cloud_sdk.agentgateway._models import MCPTool
from sap_cloud_sdk.agentgateway.config import ClientConfig
from sap_cloud_sdk.agentgateway._customer import (
detect_customer_agent_credentials,
load_customer_credentials,
Expand Down Expand Up @@ -72,15 +73,18 @@ class AgentGatewayClient:
def __init__(
self,
tenant_subdomain: str | Callable[[], str] | None = None,
config: ClientConfig | None = None,
):
"""Initialize the Agent Gateway client.

Args:
tenant_subdomain: Tenant subdomain for multi-tenant lookup.
Can be a string or a callable returning a string.
Required for LoB agents, ignored for Customer agents.
config: Client configuration. Uses defaults if not provided.
"""
self._tenant_subdomain = tenant_subdomain
self._config = config or ClientConfig()

@staticmethod
def _resolve_value(
Expand Down Expand Up @@ -153,21 +157,24 @@ async def list_mcp_tools(
"Customer agent credentials detected at '%s'", credentials_path
)
credentials = load_customer_credentials(credentials_path)
return await get_mcp_tools_customer(credentials, app_tid)
return await get_mcp_tools_customer(
credentials, self._config.timeout, app_tid
)

# LoB flow - requires tenant_subdomain
if app_tid:
logger.warning("app_tid parameter ignored for LoB agent flow")

tenant = self._resolve_tenant_subdomain()
return await get_mcp_tools_lob(tenant)
return await get_mcp_tools_lob(tenant, self._config.timeout)

except AgentGatewaySDKError:
# Re-raise SDK errors as-is
raise
except Exception as e:
logger.exception("Unexpected error during tool discovery")
raise AgentGatewaySDKError(f"Tool discovery failed: {e}") from e
cause = _unwrap_exception_group(e)
raise AgentGatewaySDKError(f"Tool discovery failed: {cause}") from e

@record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_CALL_MCP_TOOL)
async def call_mcp_tool(
Expand Down Expand Up @@ -240,7 +247,12 @@ async def call_mcp_tool(

credentials = load_customer_credentials(credentials_path)
return await call_mcp_tool_customer(
credentials, tool, resolved_user_token, app_tid, **kwargs
credentials,
tool,
resolved_user_token,
self._config.timeout,
app_tid,
**kwargs,
)

# LoB flow - requires user_token and tenant_subdomain
Expand All @@ -253,20 +265,31 @@ async def call_mcp_tool(
logger.warning("app_tid parameter ignored for LoB agent flow")

tenant = self._resolve_tenant_subdomain()
return await call_mcp_tool_lob(tool, resolved_user_token, tenant, **kwargs)
return await call_mcp_tool_lob(
tool, resolved_user_token, tenant, self._config.timeout, **kwargs
)

except AgentGatewaySDKError:
# Re-raise SDK errors as-is
raise
except Exception as e:
logger.exception("Unexpected error during tool invocation")
cause = _unwrap_exception_group(e)
raise AgentGatewaySDKError(
f"Tool invocation failed for '{tool.name}': {e}"
f"Tool invocation failed for '{tool.name}': {cause}"
) from e


def _unwrap_exception_group(exc: BaseException) -> BaseException:
"""Unwrap nested ExceptionGroups to present meaningful error messages."""
while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
exc = exc.exceptions[0]
return exc


def create_client(
tenant_subdomain: str | Callable[[], str] | None = None,
config: ClientConfig | None = None,
) -> AgentGatewayClient:
"""Create an Agent Gateway client for discovering and invoking MCP tools.

Expand All @@ -277,6 +300,7 @@ def create_client(
tenant_subdomain: Tenant subdomain for multi-tenant lookup.
Can be a string or a callable returning a string.
Required for LoB agents, ignored for Customer agents.
config: Client configuration. Uses defaults if not provided.

Returns:
AgentGatewayClient instance.
Expand Down Expand Up @@ -319,4 +343,4 @@ def create_client(
)
```
"""
return AgentGatewayClient(tenant_subdomain=tenant_subdomain)
return AgentGatewayClient(tenant_subdomain=tenant_subdomain, config=config)
17 changes: 17 additions & 0 deletions src/sap_cloud_sdk/agentgateway/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Configuration for Agent Gateway client."""

from dataclasses import dataclass

DEFAULT_TIMEOUT_SECONDS = 60.0


@dataclass
class ClientConfig:
"""Configuration options for the Agent Gateway client.
Attributes:
timeout: HTTP timeout in seconds for token requests and MCP server calls.
Defaults to 60 seconds.
"""

timeout: float = DEFAULT_TIMEOUT_SECONDS
Loading
Loading