Skip to content
Closed
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.31.0"
version = "0.32.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 2 additions & 0 deletions src/sap_cloud_sdk/agentgateway/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
from sap_cloud_sdk.agentgateway.agw_client import create_client, AgentGatewayClient
from sap_cloud_sdk.agentgateway.exceptions import (
AgentGatewaySDKError,
AgentGatewayServerError,
MCPServerNotFoundError,
)

Expand All @@ -82,5 +83,6 @@
"AgentCardFilter",
# Exceptions
"AgentGatewaySDKError",
"AgentGatewayServerError",
"MCPServerNotFoundError",
]
126 changes: 91 additions & 35 deletions src/sap_cloud_sdk/agentgateway/_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,20 @@
)
from sap_cloud_sdk.agentgateway._token_cache import _TokenCache
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError
from sap_cloud_sdk.core.secret_resolver import resolve_base_mount

logger = logging.getLogger(__name__)

# Environment variable to override default credential path
# Environment variable to override default credential path (points directly to credentials file)
_CREDENTIALS_PATH_ENV = "AGW_CREDENTIALS_PATH"

# Default credential path for Kyma production deployments
_CREDENTIALS_DEFAULT_PATH = "/etc/ums/credentials/credentials"
# servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals the expected type
_BINDING_TYPE = "integration-credentials"
_BINDING_TYPE_FILE = "type"
_CREDENTIALS_FILE = "credentials"

# Kyma default when SERVICE_BINDING_ROOT is not set
_DEFAULT_BINDING_ROOT = "/bindings"

# 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 @@ -66,24 +72,38 @@ def detect_customer_agent_credentials() -> str | None:
"""Check if customer agent credentials file exists.

Checks for credential file in the following order:
1. Path specified in AGW_CREDENTIALS_PATH env var
2. Default mounted path: /etc/ums/credentials/credentials
1. Path specified in AGW_CREDENTIALS_PATH env var (explicit override, points to file directly)
2. $SERVICE_BINDING_ROOT (or /bindings if unset): scans all subdirectories for one whose
'type' file contains 'integration-credentials', then reads 'credentials' from that directory

Returns:
Path to credentials file if found, None otherwise.
"""
# Check env var first (path may be customized)
# 1. Explicit override via env var
path_from_env = os.environ.get(_CREDENTIALS_PATH_ENV)
if path_from_env and os.path.isfile(path_from_env):
logger.debug("Customer credentials found at env var path: %s", path_from_env)
return path_from_env

# Check default mounted path
if os.path.isfile(_CREDENTIALS_DEFAULT_PATH):
logger.debug(
"Customer credentials found at default path: %s", _CREDENTIALS_DEFAULT_PATH
)
return _CREDENTIALS_DEFAULT_PATH
# 2. servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals _BINDING_TYPE
sbr = resolve_base_mount(_DEFAULT_BINDING_ROOT)
if sbr and os.path.isdir(sbr):
for entry in os.scandir(sbr):
if not entry.is_dir():
continue
type_file = os.path.join(entry.path, _BINDING_TYPE_FILE)
if not os.path.isfile(type_file):
continue
with open(type_file) as f:
if f.read().strip() != _BINDING_TYPE:
continue
credentials_path = os.path.join(entry.path, _CREDENTIALS_FILE)
if os.path.isfile(credentials_path):
logger.debug(
"Customer credentials found via servicebinding.io type scan: %s",
credentials_path,
)
return credentials_path

return None

Expand Down Expand Up @@ -128,16 +148,17 @@ def load_customer_credentials(path: str) -> CustomerCredentials:
if _CredentialFields.INTEGRATION_DEPENDENCIES not in data:
raise AgentGatewaySDKError(
"Credentials file missing required field: integrationDependencies. "
'Expected format: [{"ordId": "...", "data": {"globalTenantId": "..."}}]'
'Expected format: [{"ordId": "...", "globalTenantId": "..."}]'
)

try:
integration_deps = [
IntegrationDependency(
ord_id=dep[_CredentialFields.ORD_ID],
global_tenant_id=dep[_CredentialFields.DATA][
_CredentialFields.GLOBAL_TENANT_ID
],
global_tenant_id=(
dep.get(_CredentialFields.GLOBAL_TENANT_ID)
or dep[_CredentialFields.DATA][_CredentialFields.GLOBAL_TENANT_ID]
),
)
for dep in data[_CredentialFields.INTEGRATION_DEPENDENCIES]
]
Expand All @@ -148,7 +169,7 @@ def load_customer_credentials(path: str) -> CustomerCredentials:
except (KeyError, TypeError) as e:
raise AgentGatewaySDKError(
f"Failed to parse integrationDependencies: {e}. "
'Expected format: [{"ordId": "...", "data": {"globalTenantId": "..."}}]'
'Expected format: [{"ordId": "...", "globalTenantId": "..."}]'
)

return CustomerCredentials(
Expand Down Expand Up @@ -216,7 +237,6 @@ def _request_token_mtls(
credentials: CustomerCredentials,
grant_type: str,
timeout: float,
app_tid: str | None = None,
extra_data: dict | None = None,
) -> dict:
"""Make mTLS token request to IAS.
Expand All @@ -225,7 +245,6 @@ def _request_token_mtls(
credentials: Customer credentials with certificate and private key.
grant_type: OAuth2 grant type.
timeout: HTTP timeout in seconds.
app_tid: BTP Application Tenant ID of subscriber (optional).
extra_data: Additional form data for the token request.

Returns:
Expand All @@ -242,11 +261,6 @@ def _request_token_mtls(
"resource": _AGW_RESOURCE_URN,
}

# TODO: app_tid requirement is still being clarified with the IBD team.
# This parameter may be removed if it turns out to be unnecessary.
if app_tid:
data["app_tid"] = app_tid

if extra_data:
data.update(extra_data)

Expand Down Expand Up @@ -326,7 +340,6 @@ def get_system_token_mtls(
credentials,
grant_type=_GRANT_TYPE_CLIENT_CREDENTIALS,
timeout=timeout,
app_tid=app_tid,
extra_data={"response_type": "token"},
)
access_token = token_data["access_token"]
Expand Down Expand Up @@ -372,15 +385,17 @@ def exchange_user_token(
return cached_token

logger.info("Exchanging user token for AGW-scoped token via jwt-bearer grant")
extra: dict = {
"assertion": user_token,
"token_format": "jwt",
}
if app_tid:
extra["app_tid"] = app_tid
token_data = _request_token_mtls(
credentials,
grant_type=_GRANT_TYPE_JWT_BEARER,
timeout=timeout,
app_tid=app_tid,
extra_data={
"assertion": user_token,
"token_format": "jwt",
},
extra_data=extra,
)
access_token = token_data["access_token"]

Expand Down Expand Up @@ -476,26 +491,64 @@ async def _list_server_tools(
]


def _resolve_dependency(
credentials: CustomerCredentials,
ord_id: str,
) -> IntegrationDependency:
"""Resolve a single IntegrationDependency by ORD ID.

Args:
credentials: Customer credentials with integrationDependencies.
ord_id: ORD ID to look up.

Returns:
The matching IntegrationDependency.

Raises:
AgentGatewaySDKError: If no match is found or multiple entries share the same ORD ID.
"""
matches = [d for d in credentials.integration_dependencies if d.ord_id == ord_id]
if not matches:
available = ", ".join(d.ord_id for d in credentials.integration_dependencies)
raise AgentGatewaySDKError(
f"ORD ID '{ord_id}' not found in integrationDependencies. "
f"Available: {available or '(none)'}"
)
if len(matches) > 1:
tids = ", ".join(m.global_tenant_id for m in matches)
raise AgentGatewaySDKError(
f"ORD ID '{ord_id}' matches multiple integrationDependencies with "
f"different tenant IDs ({tids}). Specify the tenant ID explicitly."
)
return matches[0]


async def get_mcp_tools_customer(
credentials: CustomerCredentials,
system_token: str,
timeout: float,
ord_id: str | None = None,
) -> list[MCPTool]:
"""List all MCP tools from servers defined in credentials.
"""List MCP tools from servers defined in credentials.

Iterates over all integrationDependencies in the credentials file and
discovers tools from each MCP server using a pre-fetched system token.
When ord_id is given, only tools from that single dependency are returned
and the tenant ID is derived automatically from the credentials.
When ord_id is omitted, tools from all integrationDependencies are returned.

Args:
credentials: Customer credentials with integrationDependencies.
system_token: Pre-fetched raw system token for authentication.
timeout: HTTP timeout in seconds for MCP server calls.
ord_id: Optional ORD ID to filter to a single server. The tenant ID
is derived from the credentials — no need to pass it separately.
Raises AgentGatewaySDKError if the ORD ID matches multiple entries.

Returns:
List of MCPTool objects from all servers.
List of MCPTool objects from the matching server(s).

Raises:
AgentGatewaySDKError: If integrationDependencies is empty.
AgentGatewaySDKError: If integrationDependencies is empty, or if ord_id
is given but not found or matches multiple tenant IDs.
"""
dependencies = credentials.integration_dependencies

Expand All @@ -504,6 +557,9 @@ async def get_mcp_tools_customer(
"integrationDependencies is empty in credentials — no MCP servers configured."
)

if ord_id is not None:
dependencies = [_resolve_dependency(credentials, ord_id)]

logger.info("Discovering tools from %d MCP server(s)", len(dependencies))

tools: list[MCPTool] = []
Expand Down
93 changes: 93 additions & 0 deletions src/sap_cloud_sdk/agentgateway/_mcp_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Shared helpers for MCP session management."""

import logging
import uuid

import httpx
from mcp import ClientSession, McpError
from mcp.client.streamable_http import streamable_http_client

from sap_cloud_sdk.agentgateway._models import MCPTool
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewayServerError

logger = logging.getLogger(__name__)


async def invoke_mcp_tool(

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.

How s this different from run_mcp_tool?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

could you point me to run_mcp_tool please, not sure where this comes?

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.

tool: MCPTool, auth_token: str, timeout: float, **kwargs
) -> str:
"""Open an MCP session, call a tool, and return its text result.

Handles McpError from both initialize() and call_tool(), and checks
result.isError, raising AgentGatewayServerError in all three cases.

Args:
tool: MCPTool to invoke.
auth_token: Raw bearer token for the Authorization header.
timeout: HTTP timeout in seconds.
**kwargs: Tool input parameters forwarded to call_tool().

Returns:
Tool result as a string, or "" if the response has no content.

Raises:
AgentGatewayServerError: If the server returns any kind of error.
"""
async with httpx.AsyncClient(
headers={
"Authorization": f"Bearer {auth_token}",
"x-correlation-id": str(uuid.uuid4()),
},
timeout=timeout,
) as http_client:
try:
async with streamable_http_client(tool.url, http_client=http_client) as (
read,
write,
_,
):
async with ClientSession(read, write) as session:
try:
await session.initialize()
except McpError as e:
raise AgentGatewayServerError(
f"Agent Gateway rejected MCP session for tool '{tool.name}': {e.error.message}",
error_code=e.error.code,
) from e
try:
result = await session.call_tool(tool.name, kwargs)
except McpError as e:
raise AgentGatewayServerError(
f"Agent Gateway returned error for tool '{tool.name}': {e.error.message}",
error_code=e.error.code,
) from e
if result is None:
logger.warning("Tool '%s' returned a null result", tool.name)
return ""
if result.isError:
raise AgentGatewayServerError(
f"Tool '{tool.name}' returned an error: {_error_text(result.content)}"
)
if not result.content:
return ""
return str(getattr(result.content[0], "text", ""))
except BaseExceptionGroup as eg:
# anyio wraps task-group exceptions into ExceptionGroups. If the only
# leaf exception is AttributeError it means an older MCP library version
# crashed on a null result body inside call_tool. Re-raise anything else.
attr_errors, rest = eg.split(AttributeError)
if rest is not None:
raise
logger.warning(
"Tool '%s' returned a null result (MCP null-result bug: %s)",
tool.name,
attr_errors,
)
return ""


def _error_text(content: list) -> str:
"""Extract a human-readable message from MCP error content blocks."""
texts = [getattr(block, "text", None) for block in content]
message = " ".join(t for t in texts if t)
return message or "unknown error"
Loading
Loading