This module enables SAP AI agents to be extended at runtime with third-party tools (delivered via MCP servers) and custom instructions. It communicates with UMS (Unified Metadata Service) via GraphQL to retrieve the active extension's contribution, and provides helpers to declare extensible capabilities in the agent's A2A card for discovery.
This package is part of the application_foundation SDK. Import and use it directly in your application.
from sap_cloud_sdk.extensibility import (
# Runtime
create_client,
ExtensibilityClient,
ExtensionCapabilityImplementation,
McpServer,
Hook,
# A2A card
build_extension_capabilities,
ExtensionCapability,
ToolAdditions,
Tools,
HookCapability,
# Config & constants
ExtensibilityConfig,
DEFAULT_EXTENSION_CAPABILITY_ID,
EXTENSION_CAPABILITY_SCHEMA_VERSION,
# Enums
HookType,
DeploymentType,
ExecutionMode,
OnFailure,
# Exceptions
ClientCreationError,
ExtensibilityError,
TransportError,
)
# For hook payloads
from a2a.types import Message, RoleCreate a client with create_client(), then call get_extension_capability_implementation() on the client. If the service is unavailable, the method returns an empty result and the agent continues with built-in tools only.
from sap_cloud_sdk.extensibility import create_client
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(
tenant=tenant_id,
)
for server in ext.mcp_servers:
print(server.ord_id)
if server.tool_names:
print("Approved tools:", server.tool_names)
for hook in ext.hooks:
print(hook.id, hook.n8n_workflow_config.workflow_id)
if ext.instruction:
print("Extension instruction:", ext.instruction)Reuse the same client instance for multiple calls (e.g. per request lifecycle) to avoid rebuilding the destination-backed transport each time.
Define what parts of the agent are extensible and serialize them into the agent's A2A card:
from sap_cloud_sdk.extensibility import (
ExtensionCapability,
build_extension_capabilities,
)
capabilities = [
ExtensionCapability(
display_name="Onboarding Workflow",
description="Add tools to the onboarding workflow.",
),
]
agent_extensions = build_extension_capabilities(capabilities)
# Returns List[AgentExtension] for inclusion in AgentCapabilities.extensions-
Extension Capability (design-time): A declaration by the agent developer describing what parts of the agent can be extended. Serialized into the A2A card via
build_extension_capabilities(). This is metadata only -- it carries no runtime data. Currently each agent supports a single extension capability (ID"default"). Support for multiple capabilities per agent is planned for a future release. -
Extension Capability Implementation (runtime): The active extension's contribution retrieved from the extensibility service at runtime. Contains MCP servers (with tool filtering) and an optional custom instruction.
-
MCP Server: A Model Context Protocol server contributed by an extension. Each server has an ORD ID and an optional allowlist of approved tool names.
-
Hook: A workflow to be executed before or after the agent execution. Each hook has a unique UUID
id, a developer-facinghook_id(not guaranteed to be unique), and ann8n_workflow_configcontaining the workflow ID and HTTP method. Hook payloads and responses use theMessagetype from thea2a.typesmodule for standardized agent-to-agent communication. -
UMS (Unified Metadata Service): The SAP backend service that manages agent extensions. The module communicates with it via GraphQL over mTLS, using the BTP Destination Service for URL and credential resolution.
-
Graceful Degradation: A core design principle. The extensibility module never prevents agent startup or causes agent failures. Both
create_client()andExtensibilityClient.get_extension_capability_implementation()handle errors internally:- If the client cannot be constructed (e.g. missing destination credentials in local development),
create_client()logs the error and returns a client backed by a no-op transport that always returns empty results. - If the extensibility service is unavailable or any error occurs during
get_extension_capability_implementation(), the client logs the error and returns an empty result (no MCP servers, no instruction). - In both cases, the agent continues operating with its built-in tools. No errors are raised to the caller.
- If the client cannot be constructed (e.g. missing destination credentials in local development),
Factory that builds an ExtensibilityClient with a transport wired to your BTP destination configuration. This function never raises. If the client cannot be constructed (e.g. missing destination credentials in local development), it logs the error and returns a client backed by a no-op transport that always returns empty results.
def create_client(
agent_ord_id: str,
*,
config: Optional[ExtensibilityConfig] = None,
) -> ExtensibilityClient: ...agent_ord_id: ORD ID of the agent (e.g.,"sap.ai:agent:myAgent:v1"). Required for the backend transport to identify the agent when querying the extensibility service. Ignored when local mode is active.config: Optional overrides for destination name and instance. Defaults toExtensibilityConfig().
Retrieves the active extension's MCP servers and instruction from the extensibility service.
def get_extension_capability_implementation(
self,
*,
tenant: str,
capability_id: str = "default",
skip_cache: bool = False,
) -> ExtensionCapabilityImplementation: ...tenant: Tenant ID for the request. Used to filter extensions in the UMS GraphQL query and sent as theX-TenantHTTP header. Also used as a cache isolation key. Typically extracted from the incoming request's JWT.capability_id: Extension capability to look up. Defaults to"default", which is the only supported value currently. The parameter exists to support multiple capabilities per agent in a future release.skip_cache: WhenTrue, bypasses the transport-level cache and fetches a fresh result from UMS. The fresh result is still written back into the cache. Defaults toFalse.- Returns an
ExtensionCapabilityImplementation. On any error during the request, returns an instance with an emptymcp_serverslist.
Executes a hook endpoint with the provided payload.
def call_hook(
self,
hook: Hook,
hook_config: HookConfig,
) -> Optional[Message]: ...hook: Hook configuration object containing workflow config (n8n_workflow_config), timeout, and other settings.hook_config: Hook invocation configuration (endpoint, optionalauth_token, and optionalpayload).- Returns the response data as a
Messageobject, orNoneif no message is produced. - Raises
TransportErrorif the HTTP request fails or the response cannot be parsed as a validMessage. - The hook's
timeoutsetting is used for the HTTP request timeout. - The hook HTTP method is taken from
hook.n8n_workflow_config.method. - The workflow ID is taken from
hook.n8n_workflow_config.workflow_id.
Workflow configuration embedded in each Hook.
@dataclass
class N8nWorkflowConfig:
workflow_id: str # Workflow ID
method: HTTPMethod # HTTP method used by webhook executionRuntime invocation config required by call_hook().
@dataclass
class HookConfig:
endpoint: str # Full URL of the hook MCP endpoint
auth_token: Optional[str] # Bearer token for authentication
payload: Optional[dict] # Optional JSON payloadConverts extension capability declarations into A2A AgentExtension objects.
def build_extension_capabilities(
extension_capabilities: List[ExtensionCapability],
) -> List[AgentExtension]: ...Each capability is mapped to an AgentExtension with:
uri:urn:sap:extension-capability:v{version}:{id}description: from the capabilityparams: camelCase dict withcapabilityId,displayName,instructionSupported,tools(serializedTools) andsupportedHooks(serializedHookCapability)required: alwaysFalse
Validates inputs and logs warnings for empty lists, duplicate IDs, or empty IDs, but always produces output.
Design-time declaration for A2A card serialization.
@dataclass
class ExtensionCapability:
display_name: str # Human-readable name
description: str # Description of the capability
id: str = "default" # Capability identifier (only "default" currently supported)
tools: Tools = ... # Tool config (default: Tools(additions=ToolAdditions(enabled=True)))
instruction_supported: bool = True # Whether custom instructions are supported
supported_hooks: List[HookCapability] # List of supported hooksTool-related configuration for an extension capability. Groups all tool options; mirrors the wire format.
@dataclass
class Tools:
additions: ToolAdditions = ... # Tool addition config (default: enabled=True)Configuration for tool additions at an extension capability.
@dataclass
class ToolAdditions:
enabled: bool = True # Whether tool additions are enabledConfiguration for supported hook addition at an extension capability.
@dataclass
class HookCapability:
id: str
type: str
display_name: str
description: strRuntime result returned by ExtensibilityClient.get_extension_capability_implementation().
@dataclass
class ExtensionCapabilityImplementation:
capability_id: str # e.g. "default"
extension_names: List[str] = [] # Names of contributing extensions
mcp_servers: List[McpServer] = [] # MCP servers from the extension(s)
instruction: Optional[str] = None # Custom instruction text
hooks: List[Hook] = [] # Custom hooks registered as BEFORE or AFTERAn MCP server contributed by an extension.
@dataclass
class McpServer:
ord_id: str # ORD ID, e.g. "sap.mcp:apiResource:serviceNow:v1"
global_tenant_id: str # Global tenant ID of the MCP server
tool_names: Optional[List[str]] = None # Approved tools (None = all approved)global_tenant_idis the global tenant ID of the MCP server.tool_names=Nonemeans all tools on this server are approved for use (no filtering needed).tool_names=["create_ticket", "get_ticket"]means only those tools are approved.
A workflow created as a hook to be executed.
@dataclass
class Hook:
hook_id: str # Developer-facing hook key (not guaranteed unique)
id: str # Hook ID (UUID)
name: str # Human-readable name
type: HookType # Hook type (BEFORE, AFTER)
deployment_type: DeploymentType # Deployment type (N8N, SERVERLESS)
n8n_workflow_config: N8nWorkflowConfig # Workflow config (workflow ID + HTTP method)
timeout: int # Timeout in seconds
execution_mode: ExecutionMode # Execution mode (SYNC, ASYNC)
on_failure: OnFailure # Failure behavior (CONTINUE, BLOCK)
order: int # Execution order
can_short_circuit: bool # Whether hook can short-circuit executionThe Hook class uses several enums for type-safe field values:
HookType
class HookType(Enum):
BEFORE = "BEFORE" # Hook executed before an operation
AFTER = "AFTER" # Hook executed after an operationDeploymentType
class DeploymentType(Enum):
N8N = "N8N" # Hook deployed on N8N platform
SERVERLESS = "SERVERLESS" # Hook deployed as Serverless functionExecutionMode
class ExecutionMode(Enum):
SYNC = "SYNC" # Synchronous execution - waits for hook to complete
ASYNC = "ASYNC" # Asynchronous execution - does not wait for completionOnFailure
class OnFailure(Enum):
CONTINUE = "CONTINUE" # Continue execution despite hook failure
BLOCK = "BLOCK" # Block execution when hook failsHTTPMethod
from http import HTTPMethod
# Standard Python HTTP methods enum with values:
# GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECT, TRACEThese enums provide type safety and validation. When parsing from JSON, the Hook.from_dict() method validates that enum field values match known types and raises ValueError if invalid values are encountered.
Optional configuration overrides for the extensibility service connection.
@dataclass
class ExtensibilityConfig:
destination_name: Optional[str] = None # Optional destination name override
destination_instance: str = "default" # Destination service instance nameDEFAULT_EXTENSION_CAPABILITY_ID = "default"-- The only supported capability ID currently. Each agent declares a single extension capability.EXTENSION_CAPABILITY_SCHEMA_VERSION = 1-- Schema version embedded in extension capability URNs.
from sap_cloud_sdk.extensibility import create_client, ExtensibilityConfig
config = ExtensibilityConfig(
destination_name="MY_EXTENSIBILITY_DESTINATION",
destination_instance="staging",
)
client = create_client("sap.ai:agent:myAgent:v1", config=config)
ext = client.get_extension_capability_implementation(tenant=tenant_id)Currently each agent supports a single extension capability with the default ID "default". Support for multiple capabilities per agent is planned for a future release. The capability_id parameter and the id field on ExtensionCapability exist to prepare for this. For now, use the defaults:
from sap_cloud_sdk.extensibility import (
ExtensionCapability,
build_extension_capabilities,
create_client,
)
# Design-time: declare a single capability for A2A card
capabilities = [
ExtensionCapability(
display_name="Agent Extensions",
description="Extend the agent with additional tools.",
),
]
agent_extensions = build_extension_capabilities(capabilities)
# Runtime: retrieve the extension (uses default capability ID)
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)from sap_cloud_sdk.extensibility import create_client
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
for server in ext.mcp_servers:
# Connect to the MCP server
print(f"Server: {server.ord_id}")
# Filter tools if an allowlist is specified
if server.tool_names is not None:
print(f"Approved tools: {server.tool_names}")
else:
print("All tools on this server are approved")Hooks allow you to execute custom workflows before or after agent operations. The extensibility client provides a call_hook() method to invoke hook endpoints.
from sap_cloud_sdk.extensibility import create_client
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
for hook in ext.hooks:
print(f"Hook ID: {hook.id}")
print(f"Hook Key: {hook.hook_id}")
print(f"Type: {hook.type}")
print(f"Execution mode: {hook.execution_mode}")
print(f"On failure: {hook.on_failure}")Use the call_hook() method to execute a hook with a custom payload. Payloads use the Message type from a2a.types.
from sap_cloud_sdk.extensibility import create_client, HookType
from sap_cloud_sdk.extensibility.config import HookConfig
from a2a.types import Message, Role, TextPart
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
# Find a specific hook by type
before_hooks = [h for h in ext.hooks if h.type == HookType.BEFORE]
if before_hooks:
hook = before_hooks[0]
hook_config = HookConfig(
endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}",
auth_token="my-secret-token",
payload=Message(
message_id="msg-hook-call-001",
role=Role.user,
parts=[TextPart(text="Tool execution starting: create_ticket with priority=high")],
),
)
try:
response = client.call_hook(hook, hook_config)
if response:
print(f"Hook response: {response}")
else:
print("Hook returned no content (204)")
except Exception as e:
print(f"Hook execution failed: {e}")Hooks support configurable HTTP methods via hook.n8n_workflow_config.method. By default, hooks use POST, but can be configured to use GET, PUT, PATCH, or DELETE based on the hook's purpose:
from sap_cloud_sdk.extensibility import create_client
from sap_cloud_sdk.extensibility.config import HookConfig
from http import HTTPMethod
from a2a.types import Message, Role, TextPart
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
for hook in ext.hooks:
print(f"Hook {hook.name} uses HTTP {hook.n8n_workflow_config.method}")
hook_config = HookConfig(
endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}",
auth_token="my-secret-token",
payload=Message(
message_id="msg-hook-payload-001",
role=Role.user,
parts=[TextPart(text="Hook payload")],
),
)
# The client uses hook.n8n_workflow_config.method internally
response = client.call_hook(hook, hook_config)The HTTPMethod enum ensures type safety:
HTTPMethod.GET- Retrieve data from the hook endpointHTTPMethod.POST- Send data to create or process (default)HTTPMethod.PUT- Update or replace dataHTTPMethod.PATCH- Partially update dataHTTPMethod.DELETE- Delete or cancel an operation
The workflow used for execution comes from hook.n8n_workflow_config.workflow_id.
For hooks with execution_mode=ExecutionMode.SYNC, the call waits for the hook to complete:
from sap_cloud_sdk.extensibility import create_client, ExecutionMode
from sap_cloud_sdk.extensibility.config import HookConfig
from a2a.types import Message, Role, TextPart
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
for hook in ext.hooks:
if hook.execution_mode == ExecutionMode.SYNC:
hook_config = HookConfig(
endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}",
auth_token="my-secret-token",
payload=Message(
message_id="msg-sync-hook-001",
role=Role.user,
parts=[TextPart(text="Processing sync hook")],
),
)
response = client.call_hook(hook, hook_config)
# Process response immediately
if response:
print(f"Sync hook completed: {response}")Hooks can be configured with different failure behaviors via the on_failure field:
from sap_cloud_sdk.extensibility import create_client, OnFailure
from sap_cloud_sdk.extensibility.config import HookConfig
from sap_cloud_sdk.extensibility.exceptions import TransportError
from a2a.types import Message, Role, TextPart
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
for hook in ext.hooks:
hook_config = HookConfig(
endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}",
auth_token="my-secret-token",
payload=Message(
message_id="msg-validate-001",
role=Role.user,
parts=[TextPart(text="Validating operation")],
),
)
try:
response = client.call_hook(hook, hook_config)
if response:
print(f"Hook succeeded: {response}")
except TransportError as e:
if hook.on_failure == OnFailure.BLOCK:
# Hook is configured to block on failure
print(f"Critical hook failed, blocking: {e}")
raise
else: # OnFailure.CONTINUE
# Hook is configured to continue on failure
print(f"Hook failed but continuing: {e}")Hooks have an order field that specifies their execution sequence:
from sap_cloud_sdk.extensibility import create_client
from sap_cloud_sdk.extensibility.config import HookConfig
from a2a.types import Message, Role, TextPart
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
# Sort hooks by order
sorted_hooks = sorted(ext.hooks, key=lambda h: h.order)
for hook in sorted_hooks:
print(f"Executing hook {hook.name} (order: {hook.order})")
hook_config = HookConfig(
endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}",
auth_token="my-secret-token",
payload=Message(
message_id=f"msg-step-{hook.order}",
role=Role.user,
parts=[TextPart(text=f"Step {hook.order}")],
),
)
response = client.call_hook(hook, hook_config)
if response:
print(f"Response: {response}")Some hooks can short-circuit the main execution flow:
from sap_cloud_sdk.extensibility import create_client, HookType
from sap_cloud_sdk.extensibility.config import HookConfig
from a2a.types import Message, Role, TextPart
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
for hook in ext.hooks:
if hook.type == HookType.BEFORE:
hook_config = HookConfig(
endpoint="https://gateway.example.com/v1/mcp/{ORD_ID}/{GTID}",
auth_token="my-secret-token",
payload=Message(
message_id="msg-pre-validation-001",
role=Role.user,
parts=[TextPart(text="Pre-validation check")],
),
)
response = client.call_hook(hook, hook_config)
# Check if hook wants to short-circuit
if hook.can_short_circuit and response and response.metadata:
if response.metadata.get("stop_execution"):
reason = response.metadata.get("stop_execution_reason", "Unknown")
print(f"Hook requested short-circuit: {reason}")
breakfrom sap_cloud_sdk.extensibility import create_client
client = create_client("sap.ai:agent:myAgent:v1")
ext = client.get_extension_capability_implementation(tenant=tenant_id)
system_prompt = "You are a helpful assistant."
if ext.instruction:
system_prompt += f"\n\nExtension instruction:\n{ext.instruction}"The extensibility module uses graceful degradation throughout: neither create_client() nor get_extension_capability_implementation() raise exceptions to the caller. The agent always starts and always gets a usable result.
On any failure (e.g. missing destination credentials in local development), the function:
- Logs the error at
ERRORlevel with full traceback (exc_info=True) - Returns an
ExtensibilityClientbacked by a no-op transport - Subsequent calls to
get_extension_capability_implementation()on this client return empty results immediately
No try/except wrapper is needed around this call.
On any failure (network error, destination resolution failure, service unavailability), the method:
- Logs the error at
ERRORlevel with full traceback (exc_info=True) - Returns an
ExtensionCapabilityImplementationwith an emptymcp_serverslist and no instruction - The agent continues operating with built-in tools only
No try/except wrapper is needed around this call for those failures.
Validates inputs and logs warnings for:
- Empty capability lists
- Duplicate capability IDs
- Empty or whitespace-only IDs
Validation issues produce log warnings but never prevent output generation.
ExtensibilityError-- Base exception for all extensibility module errors.ClientCreationError(ExtensibilityError)-- Represents a client construction failure. Not raised bycreate_client()(which handles it internally), but available for use in custom client-construction logic.TransportError(ExtensibilityError)-- Raised by the transport layer on failure. Not seen when using the client, which catches all transport errors and returns an empty result.
The module resolves the extensibility service URL and credentials through the SAP BTP Destination Service. The destination is looked up at the subaccount level.
- Default destination name resolution: (1)
APPFND_UMS_DESTINATION_NAMEenv var, (2)sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}. If neither is available, resolution fails with a warning. - Default destination instance:
default - Override via
ExtensibilityConfig(destination_name=...)when the destination uses a non-standard name.
When running locally (without access to the extensibility service), create_client() can be backed by a local JSON file instead of the remote backend. No credentials or network access are required.
Local mode is activated in two ways, checked in order:
| Priority | Mechanism | How to activate |
|---|---|---|
| 1 | Environment variable | Set CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE to a file path |
| 2 | File-presence detection | Place a file at mocks/extensibility.json in the repository root |
The environment variable takes precedence when both are present. In either case, the JSON file must follow the same schema as the backend response.
WARNING: Local mode is for local development only. The local transport performs no authentication and reads data from a plain JSON file on disk. Never use local mode in a deployed or production environment. A warning is logged at
WARNINGlevel when file-presence detection is used; an info message is logged when the environment variable is used.
Copy the example file to mocks/extensibility.json:
mkdir -p mocks
cp src/sap_cloud_sdk/extensibility/local_extensibility_example.json mocks/extensibility.jsonThen use create_client() as usual -- it will automatically detect the file:
from sap_cloud_sdk.extensibility import create_client
client = create_client("sap.ai:agent:myAgent:v1") # Uses mocks/extensibility.json automatically
ext = client.get_extension_capability_implementation(tenant=tenant_id)The mocks/ directory is already in .gitignore to prevent accidental commits.
For CI pipelines or switching between multiple fixture files:
export CLOUD_SDK_LOCAL_EXTENSIBILITY_FILE=path/to/my/extensions.jsonfrom sap_cloud_sdk.extensibility import create_client
client = create_client("sap.ai:agent:myAgent:v1") # Uses the file from the environment variable
ext = client.get_extension_capability_implementation(tenant=tenant_id)The JSON file uses the same schema as the extensibility backend response:
{
"capabilityId": "default",
"extensionNames": ["employee-onboarding-tools"],
"instruction": "Use the ServiceNow tools for hardware provisioning tasks during onboarding.",
"mcpServers": [
{
"ordId": "sap.mcp:apiResource:serviceNow:v1",
"toolNames": ["create_hardware_ticket_tool"]
}
],
"hooks": [
{
"hookId": "hook-123",
"id": "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11",
"name": "Currency Conversion",
"type": "BEFORE",
"deploymentType": "N8N",
"n8nWorkflowConfig": {
"workflowId": "wf-currency-conversion-001",
"method": "POST"
},
"timeout": 30,
"executionMode": "SYNC",
"onFailure": "CONTINUE",
"order": 0,
"canShortCircuit": false
}
]
}See src/sap_cloud_sdk/extensibility/local_extensibility_example.json for a ready-to-use template.
- Create one
ExtensibilityClient(viacreate_client()) and reuse it for multiple capability lookups where appropriate. - The
instructionfield in the service response accepts both a plain string ("Use these tools carefully.") and a nested object ({"text": "Use these tools carefully."}). - Hook payloads and responses use the
Messagetype froma2a.typesfor type-safe, structured communication. This ensures compatibility with the Agent-to-Agent (A2A) protocol. - OpenTelemetry metrics are recorded automatically for
ExtensibilityClient.get_extension_capability_implementation()andExtensibilityClient.call_hook()calls.