Skip to content
Open
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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!-- Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# Amazon Bedrock AgentCore Gateway — Multi-ISV Orchestration (Salesforce + SAP)

This tutorial series demonstrates how to connect multiple ISV SaaS platforms (Salesforce Lightning Platform and AWS for SAP MCP Server) to a single [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html), enabling cross-system AI agent workflows through one unified endpoint.

## Tutorial Details

| Information | Details |
|:---|:---|
| Tutorial type | Interactive |
| AgentCore components | AgentCore Gateway, AgentCore Identity |
| Agentic Framework | [Strands Agents](https://github.com/strands-agents/sdk-python) |
| Gateway Target types | Integration Provider Template (Salesforce), MCP Server (SAP) |
| Inbound Auth IdP | Amazon Cognito |
| Outbound Auth | CustomOauth2 (Salesforce Connected App), CustomOauth2 (SAP Cognito) |
| LLM model | Anthropic Claude Sonnet 4.6 (`us.anthropic.claude-sonnet-4-6` — replace `us.` with your region prefix or use `global.`) |
| Tutorial vertical | Enterprise CRM + ERP |
| Example complexity | Medium |
| SDK used | boto3, requests |

## Tutorials

| # | Notebook | Description |
|---|---|---|
| 1 | [01-salesforce-gateway-target.ipynb](01-salesforce-gateway-target.ipynb) | Add Salesforce Lightning Platform via the built-in Integration Provider Template with CustomOauth2 |
| 2 | [02-sap-mcp-server-target.ipynb](02-sap-mcp-server-target.ipynb) | Add AWS for SAP MCP Server as a Gateway MCP target |
| 3 | [03-cross-isv-queries.ipynb](03-cross-isv-queries.ipynb) | Cross-system queries combining Salesforce + SAP through one gateway |

## Architecture

![Multi-ISV Orchestration Architecture](images/multi-isv-architecture.png)

## Prerequisites

- An AWS account with access to Amazon Bedrock AgentCore
- Model access enabled for `anthropic.claude-sonnet-4-6` in your region (see [Manage model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html))
- Python 3.11–3.13 (Python 3.14 is not yet supported — the AWS CRT library lacks a 3.14 wheel)
- A Salesforce Developer Edition org with a Connected App configured for OAuth2 `client_credentials` flow
- Access to an AWS for SAP MCP Server deployment (see [documentation](https://docs.aws.amazon.com/mcp-sap/latest/awsforsapmcp/introduction.html))
- AWS CLI configured with appropriate credentials

## Getting Started

1. Install dependencies:
```bash
pip install -r requirements.txt
```

2. Open the first notebook and follow the steps sequentially:
```bash
jupyter notebook 01-salesforce-gateway-target.ipynb
```

3. Each notebook will prompt you for credentials and guide you through the full setup, invocation, and cleanup process.

## Important Notes

- **Salesforce Developer Edition orgs** hibernate after ~24 hours of inactivity. Log into the Salesforce web UI to wake the org before running the notebooks.
- **CustomOauth2** is required for Salesforce Developer Edition orgs. The built-in `SalesforceOauth2` vendor hardcodes the `login.salesforce.com` OAuth endpoint. Developer Edition orgs only allow `client_credentials` on their org-specific domain (`*.develop.my.salesforce.com`), so we use `CustomOauth2` with the org's OAuth2 metadata.
- **SAP MCP Server** runs in read-only mode by default. Write operations must be explicitly enabled in the SAP MCP Server configuration.

## Disclaimer

This is sample code for demonstration purposes only. Not intended for production use without additional security review. In particular:

- **IAM permissions** in this tutorial use broad `*` resource scope for simplicity. Production deployments should scope resources to specific ARNs.
- **Salesforce target** uses the built-in Integration Provider Template, which must be created via the AWS Console (not API). See the [supported integrations](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-integrations.html).
- **Content-Type parameter** — The Salesforce schema exposes `Content-Type` as a tool parameter. Because the gateway [manages Content-Type as a restricted header](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-headers.html), pass `""` (empty string) for this parameter on create/update operations to prevent header duplication.

## License

This project is licensed under the Apache License 2.0. See the [LICENSE](../../../../LICENSE) file for details.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
Regenerate the multi-ISV orchestration architecture diagram as PNG.

Requirements:
brew install graphviz
pip install diagrams

Usage:
python3 diagrams.py
"""

import os
import sys

_here = os.path.dirname(os.path.abspath(__file__))
if _here in sys.path:
sys.path.remove(_here)

from diagrams import Diagram, Cluster, Edge
from diagrams.aws.security import Cognito, SecretsManager
from diagrams.custom import Custom
from diagrams.onprem.client import User

GRAPH = {
"bgcolor": "white",
"pad": "0.5",
"fontsize": "13",
"fontname": "Helvetica",
"splines": "curved",
}
NODE = {"fontsize": "11", "fontname": "Helvetica"}
EDGE = {"fontsize": "9", "fontname": "Helvetica"}

_ICONS = os.path.join(_here, "icons")
ICON_RUNTIME = os.path.join(_ICONS, "agentcore-runtime.png")

_C_CLIENT = dict(bgcolor="#E0F2FE", style="rounded", pencolor="#0EA5E9", penwidth="2")
_C_GATEWAY = dict(bgcolor="#FAF5FF", style="rounded", pencolor="#A855F7", penwidth="3")
_C_TARGETS = dict(bgcolor="#FAF5FF", style="rounded", pencolor="#A855F7", penwidth="2", margin="28")
_C_IDENTITY = dict(bgcolor="#F0FDF4", style="rounded", pencolor="#16A34A", penwidth="2.5")
_C_OUTBOUND = dict(bgcolor="#FEF3C7", style="rounded", pencolor="#D97706", penwidth="2")
_C_ISV = dict(bgcolor="#FFF1F2", style="rounded", pencolor="#E11D48", penwidth="2")


def multi_isv_architecture():
with Diagram(
"",
filename=os.path.join(_here, "images", "multi-isv-architecture"),
outformat="png",
show=False,
direction="LR",
graph_attr={**GRAPH, "ranksep": "2.2", "nodesep": "1.2", "size": "26,18"},
node_attr=NODE,
edge_attr=EDGE,
):
with Cluster("MCP Client / Agent", graph_attr=_C_CLIENT):
agent = User("Strands Agent\nor MCP Client")

with Cluster("Inbound Auth", graph_attr=_C_IDENTITY):
cognito = Cognito("Amazon Cognito\nclient_credentials")

with Cluster("Amazon Bedrock AgentCore Gateway", graph_attr=_C_GATEWAY):
gw = Custom("MCP Gateway\nJSON-RPC 2.0\nJWT Authorizer", ICON_RUNTIME)

with Cluster("Gateway Targets", graph_attr=_C_TARGETS):
sf_target = Custom("Salesforce Target\nOpenAPI Schema\n43 tools", ICON_RUNTIME)
sap_target = Custom("SAP Target\nMCP Server\n9 tools", ICON_RUNTIME)

with Cluster("Outbound Auth", graph_attr=_C_OUTBOUND):
sf_cred = SecretsManager("CustomOauth2\nSF Connected App")
sap_cred = SecretsManager("CustomOauth2\nSAP Cognito Pool")

with Cluster("ISV Platforms", graph_attr=_C_ISV):
sf_platform = Custom("Salesforce Lightning\nREST API v62.0", ICON_RUNTIME)
sap_platform = Custom("AWS for SAP\nMCP Server · OData V2", ICON_RUNTIME)

agent >> Edge(style="dashed", color="#16A34A") >> cognito
agent >> Edge(label="tools/list · tools/call", dir="both") >> gw
gw >> Edge(dir="both") >> sf_target
gw >> Edge(dir="both") >> sap_target
sf_target >> Edge(style="dashed", color="#D97706") >> sf_cred
sap_target >> Edge(style="dashed", color="#D97706") >> sap_cred
sf_cred >> Edge(dir="both") >> sf_platform
sap_cred >> Edge(dir="both") >> sap_platform


if __name__ == "__main__":
os.makedirs(os.path.join(_here, "images"), exist_ok=True)
print("Generating multi-isv-architecture.png ...")
multi_isv_architecture()
print("Done. Diagram saved to images/")
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""Lightweight raw-HTTP client for AgentCore Gateway's MCP endpoint.

Provides helpers for Cognito M2M token acquisition, paginated tool listing,
and tool invocation via JSON-RPC 2.0. Used by all notebooks in this tutorial
so cells stay focused on the integration logic rather than transport plumbing.
"""

from __future__ import annotations

import json
import time
from typing import Any, Callable, Dict, List, Optional

import requests


DEFAULT_PROTOCOL_VERSION = "2025-03-26"


def get_cognito_m2m_token(token_endpoint: str, client_id: str, client_secret: str, scope: str) -> str:
"""Obtain an access token via OAuth2 client_credentials grant."""
response = requests.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30,
)
response.raise_for_status()
return response.json()["access_token"]


class GatewayMCPClient:
"""Minimal client wrapping JSON-RPC POSTs to the gateway's MCP endpoint."""

def __init__(
self,
gateway_url: str,
get_token: Callable[[], str],
protocol_version: str = DEFAULT_PROTOCOL_VERSION,
session_id: Optional[str] = None,
) -> None:
self.gateway_url = gateway_url
self._get_token = get_token
self._protocol_version = protocol_version
self._session_id = session_id

def _headers(self) -> Dict[str, str]:
h = {
"Content-Type": "application/json",
"Accept": "application/json",
"MCP-Protocol-Version": self._protocol_version,
"Authorization": f"Bearer {self._get_token()}",
}
if self._session_id:
h["Mcp-Session-Id"] = self._session_id
return h

def _rpc(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"jsonrpc": "2.0",
"id": f"{method.replace('/', '-')}-request",
"method": method,
}
if params is not None:
payload["params"] = params
resp = requests.post(self.gateway_url, headers=self._headers(), json=payload, timeout=120)
resp.raise_for_status()
return resp.json()

def list_all_tools(self) -> List[Dict[str, Any]]:
"""Return tools from all targets, following per-target pagination via nextCursor."""
tools: List[Dict[str, Any]] = []
cursor: Optional[str] = None
while True:
params = {"cursor": cursor} if cursor else None
resp = self._rpc("tools/list", params)
result = resp.get("result", {})
tools.extend(result.get("tools", []))
cursor = result.get("nextCursor")
if not cursor:
return tools

def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Invoke a single tool and return the JSON-RPC result."""
return self._rpc("tools/call", {"name": name, "arguments": arguments})

def search_tools(self, query: str) -> List[Dict[str, Any]]:
"""Use the gateway's semantic search to narrow tools for a query."""
resp = self._rpc(
"tools/call",
{"name": "x_amz_bedrock_agentcore_search", "arguments": {"query": query}},
)
result = resp.get("result", {})
content = result.get("content", [])
for item in content:
if item.get("type") == "text":
try:
return json.loads(item["text"])
except (json.JSONDecodeError, KeyError):
pass
return []


def wait_for_target_ready(
client: Any,
gateway_id: str,
target_name: str,
region: str,
timeout: int = 300,
) -> str:
"""Poll gateway targets until the named target reaches READY status."""
import boto3

agentcore = boto3.client("bedrock-agentcore-control", region_name=region)
start = time.time()
while time.time() - start < timeout:
resp = agentcore.list_gateway_targets(gatewayIdentifier=gateway_id)
for item in resp.get("items", []):
if item.get("name") == target_name:
status = item.get("status")
print(f" Target '{target_name}' status: {status}")
if status == "READY":
return item.get("targetId", "")
if status in ("FAILED", "SYNCHRONIZE_UNSUCCESSFUL"):
raise RuntimeError(f"Target '{target_name}' failed with status: {status}")
time.sleep(10) # nosemgrep: arbitrary-sleep — polling interval for async target provisioning
raise TimeoutError(f"Target '{target_name}' did not reach READY within {timeout}s")
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
boto3>=1.34.0,<2.0.0
requests>=2.31.0,<3.0.0
strands-agents>=0.1.0,<1.0.0
mcp>=1.10.0,<2.0.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
line-length = 120
target-version = "py311"
Loading