diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/01-salesforce-gateway-target.ipynb b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/01-salesforce-gateway-target.ipynb new file mode 100644 index 000000000..dfe83ac42 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/01-salesforce-gateway-target.ipynb @@ -0,0 +1,342 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Salesforce Lightning Platform as AgentCore Gateway Target\n\n## Overview\n\nThis notebook walks through adding **Salesforce Lightning Platform** as a gateway target on [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) using the [built-in Integration Provider Template](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-integrations.html#gateway-target-integrations-supported-apis-salesforce) and OAuth2 (`client_credentials` flow). Once configured, the gateway exposes Salesforce REST API operations (Account, Case, Contact, Lead, Opportunity, Campaign, etc.) as MCP tools that any agent can invoke.\n\n| Information | Details |\n|:---|:---|\n| Target type | Integration Provider Template (Salesforce Lightning Platform) |\n| Outbound auth | CustomOauth2 (Salesforce Connected App, client_credentials) |\n| Inbound auth | Amazon Cognito (M2M) |\n| Tools exposed | 43 Salesforce CRUD operations (Account, Case, Contact, Lead, Opportunity, Campaign, etc.) |\n| Salesforce API version | v62.0 |" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Prerequisites\n", + "\n", + "Before starting this notebook, you need:\n", + "\n", + "1. **AWS account** with access to Amazon Bedrock AgentCore\n", + "2. **Salesforce Developer Edition org** — free at [developer.salesforce.com/signup](https://developer.salesforce.com/signup)\n", + "3. **Salesforce Connected App** configured for OAuth2 `client_credentials` flow (see Step 1 below)\n", + "4. **Python 3.11+** with Jupyter kernel\n", + "5. **AWS CLI** configured with credentials that have AgentCore permissions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "!pip install --force-reinstall -U -r requirements.txt --quiet" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import json\n", + "import logging\n", + "import os\n", + "import time\n", + "import uuid\n", + "\n", + "import boto3\n", + "import requests\n", + "from boto3.session import Session\n", + "\n", + "import gateway_mcp_client\n", + "\n", + "# AWS credentials — set your profile\n", + "os.environ[\"AWS_PROFILE\"] = \"default\" # Change to your profile\n", + "\n", + "logging.basicConfig(\n", + " level=logging.INFO,\n", + " format=\"%(asctime)s | %(levelname)s | %(name)s | %(message)s\",\n", + " handlers=[logging.StreamHandler()],\n", + ")\n", + "\n", + "session = Session()\n", + "REGION = session.region_name or \"eu-west-1\"\n", + "ACCOUNT_ID = boto3.client(\"sts\").get_caller_identity()[\"Account\"]\n", + "\n", + "# Derive Bedrock model ID geo prefix from region\n", + "GEO_PREFIX = {\"us-\": \"us\", \"eu-\": \"eu\", \"ap-\": \"ap\", \"ca-\": \"us\", \"sa-\": \"us\"}\n", + "MODEL_PREFIX = next((v for k, v in GEO_PREFIX.items() if REGION.startswith(k)), \"us\")\n", + "MODEL_ID = f\"{MODEL_PREFIX}.anthropic.claude-sonnet-4-6\"\n", + "\n", + "print(f\"Using region: {REGION}\")\n", + "print(f\"AWS account: {ACCOUNT_ID}\")\n", + "print(f\"Model ID: {MODEL_ID}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Collect Salesforce credentials (never hardcoded)\nSF_DOMAIN = input(\"Enter your Salesforce domain (e.g., myorg-dev-ed): \").strip()\nSF_CLIENT_ID = input(\"Enter your Salesforce Consumer Key (Client ID): \").strip()\nSF_CLIENT_SECRET = getpass.getpass(\"Enter your Salesforce Consumer Secret: \").strip()\n\n# Strip common suffixes if user pastes full URL\nfor suffix in [\".develop.my.salesforce.com\", \".my.salesforce.com\", \".salesforce.com\"]:\n if SF_DOMAIN.endswith(suffix):\n SF_DOMAIN = SF_DOMAIN.removesuffix(suffix)\nif SF_DOMAIN.startswith(\"https://\"):\n SF_DOMAIN = SF_DOMAIN.removeprefix(\"https://\")\n\nassert SF_DOMAIN, \"Salesforce domain cannot be empty\"\nassert SF_CLIENT_ID, \"Client ID cannot be empty\"\nassert SF_CLIENT_SECRET, \"Client Secret cannot be empty\"\n\nprint(f\"\\nSalesforce domain: {SF_DOMAIN}\")\nprint(f\"Token endpoint: https://{SF_DOMAIN}.develop.my.salesforce.com/services/oauth2/token\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1: Create a Salesforce Connected App\n", + "\n", + "If you haven't already created a Connected App, follow these steps in your Salesforce org:\n", + "\n", + "### Option A: Connected App (Classic)\n", + "\n", + "1. Log in to Salesforce → Setup (gear icon → Setup)\n", + "2. Quick Find → **App Manager** → **New Connected App**\n", + "3. Enable OAuth Settings:\n", + " - Callback URL: `https://bedrock-agentcore..amazonaws.com/identities/oauth2/callback`\n", + " - OAuth Scopes: `Full access (full)`, `Perform requests at any time (refresh_token, offline_access)`, `Manage user data via APIs (api)`\n", + "4. Security settings:\n", + " - ✅ Require Proof Key for Code Exchange (PKCE)\n", + " - ✅ Require Secret for Web Server Flow\n", + " - ✅ Enable Client Credentials Flow\n", + "5. Save → wait 2-10 minutes for propagation\n", + "6. **Critical:** App Manager → find your app → Manage → Edit Policies → Client Credentials Flow → set **Run As** to your admin username\n", + "7. Get Consumer Key + Consumer Secret from Manage Consumer Details\n", + "\n", + "### Option B: External Client App (newer orgs)\n", + "\n", + "1. Setup → **External Client App Manager** → New External Client App\n", + "2. Enable OAuth with same callback URL and scopes as above\n", + "3. Enable Client Credentials Flow with Run As user = admin username\n", + "4. Get Client ID and Secret from the app's OAuth settings page\n", + "\n", + "> **Note:** Developer Edition orgs hibernate after ~24h of inactivity. If you get HTTP 420 errors, log into the Salesforce web UI to wake the org." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Verify Salesforce OAuth2 Token\n", + "\n", + "Test that your credentials work before configuring the gateway." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Test Salesforce OAuth2 client_credentials flow\n", + "token_endpoint = f\"https://{SF_DOMAIN}.develop.my.salesforce.com/services/oauth2/token\"\n", + "\n", + "resp = requests.post(\n", + " token_endpoint,\n", + " data={\n", + " \"grant_type\": \"client_credentials\",\n", + " \"client_id\": SF_CLIENT_ID,\n", + " \"client_secret\": SF_CLIENT_SECRET,\n", + " },\n", + " headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n", + " timeout=30,\n", + ")\n", + "\n", + "if resp.status_code == 200:\n", + " sf_token_data = resp.json()\n", + " print(\"✓ Salesforce OAuth2 token obtained successfully\")\n", + " print(f\" Token type: {sf_token_data.get('token_type')}\")\n", + " print(f\" Instance URL: {sf_token_data.get('instance_url')}\")\n", + "else:\n", + " print(f\"✗ Failed to get token: {resp.status_code}\")\n", + " print(f\" Response: {resp.text}\")\n", + " raise RuntimeError(\"Fix your Salesforce credentials before proceeding\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Step 3: Create AgentCore Gateway\n\nThis cell creates all gateway infrastructure in one shot: Cognito User Pool (inbound M2M auth), IAM role (with confused-deputy protection), and the gateway itself." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "GATEWAY_NAME = f\"multi-isv-tutorial-{str(uuid.uuid4())[:8]}\"\nACCOUNT_ID = boto3.client(\"sts\").get_caller_identity()[\"Account\"]\nprint(f\"Gateway name: {GATEWAY_NAME}\")\nprint(f\"AWS account: {ACCOUNT_ID}\")\n\n# --- Cognito User Pool (inbound auth) ---\ncognito_client = boto3.client(\"cognito-idp\", region_name=REGION)\npool_resp = cognito_client.create_user_pool(\n PoolName=f\"{GATEWAY_NAME}-pool\",\n Policies={\"PasswordPolicy\": {\"MinimumLength\": 8}},\n)\nUSER_POOL_ID = pool_resp[\"UserPool\"][\"Id\"]\n\nCOGNITO_DOMAIN = f\"{GATEWAY_NAME}-domain\"\ncognito_client.create_user_pool_domain(Domain=COGNITO_DOMAIN, UserPoolId=USER_POOL_ID)\nTOKEN_ENDPOINT = f\"https://{COGNITO_DOMAIN}.auth.{REGION}.amazoncognito.com/oauth2/token\"\n\nRESOURCE_SERVER_ID = f\"{GATEWAY_NAME}-id\"\nSCOPE_NAME = \"invoke\"\ncognito_client.create_resource_server(\n UserPoolId=USER_POOL_ID,\n Identifier=RESOURCE_SERVER_ID,\n Name=f\"{GATEWAY_NAME} Gateway\",\n Scopes=[{\"ScopeName\": SCOPE_NAME, \"ScopeDescription\": \"Invoke gateway\"}],\n)\nFULL_SCOPE = f\"{RESOURCE_SERVER_ID}/{SCOPE_NAME}\"\n\napp_resp = cognito_client.create_user_pool_client(\n UserPoolId=USER_POOL_ID,\n ClientName=f\"{GATEWAY_NAME}-client\",\n GenerateSecret=True,\n AllowedOAuthFlows=[\"client_credentials\"],\n AllowedOAuthScopes=[FULL_SCOPE],\n AllowedOAuthFlowsUserPoolClient=True,\n)\nGW_CLIENT_ID = app_resp[\"UserPoolClient\"][\"ClientId\"]\nGW_CLIENT_SECRET = app_resp[\"UserPoolClient\"][\"ClientSecret\"]\nDISCOVERY_URL = f\"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}/.well-known/openid-configuration\"\nprint(f\" ✓ Cognito pool + client created\")\n\n# --- IAM Role ---\niam = boto3.client(\"iam\")\nROLE_NAME = f\"agentcore-{GATEWAY_NAME}-role\"\ntrust_policy = {\n \"Version\": \"2012-10-17\",\n \"Statement\": [{\n \"Effect\": \"Allow\",\n \"Principal\": {\"Service\": \"bedrock-agentcore.amazonaws.com\"},\n \"Action\": \"sts:AssumeRole\",\n \"Condition\": {\n \"StringEquals\": {\"aws:SourceAccount\": ACCOUNT_ID},\n \"ArnLike\": {\"aws:SourceArn\": f\"arn:aws:bedrock-agentcore:{REGION}:{ACCOUNT_ID}:*\"},\n },\n }],\n}\nrole_resp = iam.create_role(\n RoleName=ROLE_NAME,\n AssumeRolePolicyDocument=json.dumps(trust_policy),\n Description=\"IAM role for AgentCore Gateway multi-ISV tutorial\",\n)\nROLE_ARN = role_resp[\"Role\"][\"Arn\"]\niam.put_role_policy(\n RoleName=ROLE_NAME,\n PolicyName=\"AgentCorePolicy\",\n PolicyDocument=json.dumps({\n \"Version\": \"2012-10-17\",\n \"Statement\": [{\n \"Effect\": \"Allow\",\n \"Action\": [\n \"bedrock-agentcore:*\", \"bedrock:*\", \"agent-credential-provider:*\",\n \"iam:PassRole\", \"secretsmanager:GetSecretValue\", \"s3:GetObject\",\n ],\n \"Resource\": \"*\",\n }],\n }),\n)\nprint(f\" ✓ IAM role created: {ROLE_NAME}\")\ntime.sleep(10)\n\n# --- Gateway ---\ngateway_client = boto3.client(\"bedrock-agentcore-control\", region_name=REGION)\ngw_resp = gateway_client.create_gateway(\n name=GATEWAY_NAME,\n roleArn=ROLE_ARN,\n protocolType=\"MCP\",\n authorizerType=\"CUSTOM_JWT\",\n authorizerConfiguration={\n \"customJWTAuthorizer\": {\n \"discoveryUrl\": DISCOVERY_URL,\n \"allowedClients\": [GW_CLIENT_ID],\n \"allowedScopes\": [FULL_SCOPE],\n }\n },\n)\nGATEWAY_ID = gw_resp[\"gatewayId\"]\nGATEWAY_URL = gw_resp[\"gatewayUrl\"]\n\nprint(f\" Waiting for gateway READY...\")\nfor _ in range(60):\n status = gateway_client.get_gateway(gatewayIdentifier=GATEWAY_ID)[\"status\"]\n if status == \"READY\":\n break\n if status == \"FAILED\":\n raise RuntimeError(\"Gateway creation failed\")\n time.sleep(5)\nelse:\n raise TimeoutError(\"Gateway did not reach READY within 5 minutes\")\n\nprint(f\"\\n{'='*60}\")\nprint(f\" ✓ GATEWAY IS READY\")\nprint(f\"{'='*60}\")\nprint(f\" Gateway name: {GATEWAY_NAME}\")\nprint(f\" Gateway ID: {GATEWAY_ID}\")\nprint(f\" Gateway URL: {GATEWAY_URL}\")\nprint(f\"{'='*60}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Create CustomOauth2 Credential Provider\n", + "\n", + "AgentCore Gateway needs OAuth2 credentials to authenticate to Salesforce on behalf of the agent.\n", + "\n", + "> **Important:** Use `CustomOauth2` — NOT `SalesforceOauth2`. The built-in Salesforce vendor uses `login.salesforce.com` which does not support `client_credentials` on Developer Edition org domains. With `CustomOauth2`, we specify the org-specific token endpoint directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "CREDENTIAL_PROVIDER_NAME = f\"{GATEWAY_NAME}-sf-oauth\"\n\nidentity_client = boto3.client(\"bedrock-agentcore-control\", region_name=REGION)\n\ncred_resp = identity_client.create_oauth2_credential_provider(\n name=CREDENTIAL_PROVIDER_NAME,\n credentialProviderVendor=\"CustomOauth2\",\n oauth2ProviderConfigInput={\n \"customOauth2ProviderConfig\": {\n \"clientId\": SF_CLIENT_ID,\n \"clientSecret\": SF_CLIENT_SECRET,\n \"oauthDiscovery\": {\n \"discoveryUrl\": f\"https://{SF_DOMAIN}.develop.my.salesforce.com/.well-known/openid-configuration\",\n },\n }\n },\n)\n\nCREDENTIAL_PROVIDER_ARN = cred_resp[\"credentialProviderArn\"]\nprint(f\"Created credential provider: {CREDENTIAL_PROVIDER_NAME}\")\nprint(f\"ARN: {CREDENTIAL_PROVIDER_ARN}\")" + }, + { + "cell_type": "code", + "source": "# Summary — copy these values for the Console step and for notebooks 02/03\nprint(f\"\"\"\n{'='*60}\n CONNECTION DETAILS (save for notebooks 02 + 03)\n{'='*60}\n Gateway name: {GATEWAY_NAME}\n Gateway ID: {GATEWAY_ID}\n Gateway URL: {GATEWAY_URL}\n Cognito token endpoint: {TOKEN_ENDPOINT}\n Cognito client ID: {GW_CLIENT_ID}\n Cognito client secret: {GW_CLIENT_SECRET[:8]}...\n OAuth scope: {FULL_SCOPE}\n Credential provider: {CREDENTIAL_PROVIDER_NAME}\n{'='*60}\n\n FOR THE CONSOLE STEP (Step 5):\n - Gateway: {GATEWAY_NAME}\n - Target name: salesforce-target\n - Server URL: https://{{domainName}}.develop.my.salesforce.com/services/data/v62.0\n - Credential provider: {CREDENTIAL_PROVIDER_NAME}\n{'='*60}\n\"\"\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Step 5: Add Salesforce as Gateway Target (Console)\n\nThe Salesforce integration uses the **built-in Integration Provider Template** — a pre-defined OpenAPI schema managed by AgentCore. This target type can only be created via the [AWS Management Console](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-integrations.html#gateway-target-integrations-supported-apis-salesforce).\n\n### Steps:\n\n1. Open the [AgentCore Gateway console](https://console.aws.amazon.com/bedrock-agentcore/home#/gateways)\n2. Select your gateway (`GATEWAY_NAME` printed above)\n3. Click **Add target**\n4. Select **Integration provider template** → **Salesforce Lightning Platform**\n5. Configure:\n - **Target name:** `salesforce-target`\n - **Server URL:** `https://{domainName}.develop.my.salesforce.com/services/data/v62.0`\n - **Authentication:** OAuth2\n - **Credential provider:** Select the credential provider created above\n6. Click **Create target**\n7. Wait for target status to become **READY** (1–2 minutes)\n\nOnce the target is READY, run the next cell to verify." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Verify the Salesforce target is READY\nSF_TARGET_NAME = \"salesforce-target\"\n\ngateway_mcp_client.wait_for_target_ready(\n client=gateway_client,\n gateway_id=GATEWAY_ID,\n target_name=SF_TARGET_NAME,\n region=REGION,\n timeout=300,\n)\n\n# Get the target ID for cleanup later\ntargets = gateway_client.list_gateway_targets(gatewayIdentifier=GATEWAY_ID)\nSF_TARGET_ID = next(\n t[\"targetId\"] for t in targets[\"items\"] if t[\"name\"] == SF_TARGET_NAME\n)\nprint(f\"✓ Salesforce target is READY (ID: {SF_TARGET_ID})\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6: List Salesforce Tools via Gateway\n", + "\n", + "Now that the target is ready, we can list all available Salesforce tools through the gateway's MCP endpoint." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get a gateway access token\n", + "def get_gw_token() -> str:\n", + " return gateway_mcp_client.get_cognito_m2m_token(\n", + " token_endpoint=TOKEN_ENDPOINT,\n", + " client_id=GW_CLIENT_ID,\n", + " client_secret=GW_CLIENT_SECRET,\n", + " scope=FULL_SCOPE,\n", + " )\n", + "\n", + "mcp = gateway_mcp_client.GatewayMCPClient(\n", + " gateway_url=GATEWAY_URL,\n", + " get_token=get_gw_token,\n", + " session_id=str(uuid.uuid4()),\n", + ")\n", + "\n", + "# List all tools (handles pagination automatically)\n", + "all_tools = mcp.list_all_tools()\n", + "sf_tools = [t for t in all_tools if t[\"name\"].startswith(\"salesforce-target___\")]\n", + "\n", + "print(f\"Total tools on gateway: {len(all_tools)}\")\n", + "print(f\"Salesforce tools: {len(sf_tools)}\")\n", + "print(\"\\nSalesforce tool names:\")\n", + "for t in sorted(sf_tools, key=lambda x: x[\"name\"]):\n", + " print(f\" - {t['name']}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 7: Invoke Salesforce Tools\n", + "\n", + "Let's invoke Salesforce tools through the gateway.\n", + "\n", + "> **Important:** Every Salesforce tool call requires the `domainName` parameter. Without it, the gateway resolves to a non-existent default domain." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Query accounts using SOQL\nresult = mcp.call_tool(\n \"salesforce-target___queryAccounts\",\n {\"domainName\": SF_DOMAIN, \"q\": \"SELECT Id, Name, Industry FROM Account LIMIT 5\"},\n)\nprint(\"=== Salesforce Accounts ===\")\ncontent = result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n print(f\" Total records: {data.get('totalSize', '?')}\\n\")\n for rec in data.get(\"records\", []):\n print(f\" {rec.get('Name', '?'):40s} Industry: {rec.get('Industry', 'N/A')}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Get account list (returns recently viewed accounts)\nresult = mcp.call_tool(\n \"salesforce-target___getAccountList\",\n {\"domainName\": SF_DOMAIN},\n)\nprint(\"=== Recently Viewed Accounts ===\")\ncontent = result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n for rec in data.get(\"recentItems\", data.get(\"records\", []))[:5]:\n print(f\" {rec.get('Name', rec.get('Id', '?'))}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Create a test account\n# Content-Type is a restricted header managed by the gateway — pass \"\" to avoid duplication\nresult = mcp.call_tool(\n \"salesforce-target___createAccount\",\n {\n \"domainName\": SF_DOMAIN,\n \"Content-Type\": \"\",\n \"Name\": \"AgentCore Tutorial Test Account\",\n \"Industry\": \"Technology\",\n \"Description\": \"Created by AgentCore Gateway multi-ISV tutorial\",\n },\n)\nprint(\"=== Create Account ===\")\ncontent = result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n if data.get(\"success\"):\n print(f\" ✓ Account created: {data['id']}\")\n else:\n print(f\" ✗ Failed: {data.get('errors', data)}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Describe an SObject to see available fields\nresult = mcp.call_tool(\n \"salesforce-target___describeSObject\",\n {\"domainName\": SF_DOMAIN, \"sObject\": \"Account\"},\n)\nprint(\"=== Account SObject Fields ===\")\ncontent = result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n fields = data.get(\"fields\", [])\n print(f\" Total fields: {len(fields)}\\n First 10:\")\n for f in fields[:10]:\n print(f\" {f['name']:30s} ({f['type']})\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 8: Use Strands Agent with Salesforce Tools\n", + "\n", + "Now let's connect a Strands Agent to the gateway and let it use Salesforce tools via natural language." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from strands import Agent\nfrom strands.tools.mcp import MCPClient\nfrom mcp.client.streamable_http import streamablehttp_client\n\n# Connect to gateway via MCP\nmcp_client = MCPClient(\n lambda: streamablehttp_client(\n url=GATEWAY_URL,\n headers={\n \"Authorization\": f\"Bearer {get_gw_token()}\",\n \"MCP-Protocol-Version\": \"2025-03-26\",\n },\n )\n)\n\nSYSTEM_PROMPT = (\n \"You are a helpful assistant with access to Salesforce tools. \"\n f\"Always include domainName='{SF_DOMAIN}' in every Salesforce tool call. \"\n \"Use queryAccounts with SOQL for listing accounts rather than getAccountList.\"\n)\n\nwith mcp_client:\n agent = Agent(\n model=MODEL_ID,\n system_prompt=SYSTEM_PROMPT,\n tools=mcp_client.list_tools_sync(),\n )\n result = agent(\"List all Salesforce accounts with their industry\")\n print(result)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 9: Clean Up\n", + "\n", + "Delete all resources created in this notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Delete test data created in Salesforce\nprint(\"Cleaning up Salesforce test data...\")\ntry:\n query_result = mcp.call_tool(\n \"salesforce-target___queryAccounts\",\n {\"domainName\": SF_DOMAIN, \"q\": \"SELECT Id FROM Account WHERE Name = 'AgentCore Tutorial Test Account'\"},\n )\n content = query_result.get(\"result\", {}).get(\"content\", [])\n for item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n for record in data.get(\"records\", []):\n mcp.call_tool(\n \"salesforce-target___deleteAccount\",\n {\"domainName\": SF_DOMAIN, \"Content-Type\": \"\", \"accountId\": record[\"Id\"]},\n )\n print(f\" ✓ Deleted test account {record['Id']}\")\nexcept Exception as e:\n print(f\" (Could not clean up SF test data: {e})\")\n\n# Delete gateway target\nprint(\"Deleting Salesforce gateway target...\")\ngateway_client.delete_gateway_target(\n gatewayIdentifier=GATEWAY_ID,\n targetId=SF_TARGET_ID,\n)\ntime.sleep(5)\nprint(\" ✓ Target deleted\")\n\n# Delete credential provider\nprint(\"Deleting credential provider...\")\nidentity_client.delete_oauth2_credential_provider(name=CREDENTIAL_PROVIDER_NAME)\nprint(\" ✓ Credential provider deleted\")\n\n# Delete gateway\nprint(\"Deleting gateway...\")\ngateway_client.delete_gateway(gatewayIdentifier=GATEWAY_ID)\ntime.sleep(5)\nprint(\" ✓ Gateway deleted\")\n\n# Delete Cognito resources\nprint(\"Deleting Cognito resources...\")\ncognito_client.delete_user_pool_domain(Domain=COGNITO_DOMAIN, UserPoolId=USER_POOL_ID)\ncognito_client.delete_user_pool(UserPoolId=USER_POOL_ID)\nprint(\" ✓ Cognito pool deleted\")\n\n# Delete IAM role (must remove inline policy first)\nprint(\"Deleting IAM role...\")\niam.delete_role_policy(RoleName=ROLE_NAME, PolicyName=\"AgentCorePolicy\")\niam.delete_role(RoleName=ROLE_NAME)\nprint(\" ✓ IAM role deleted\")\n\nprint(\"\\n✓ All resources cleaned up\")" + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/02-sap-mcp-server-target.ipynb b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/02-sap-mcp-server-target.ipynb new file mode 100644 index 000000000..f7e55ede3 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/02-sap-mcp-server-target.ipynb @@ -0,0 +1,279 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AWS for SAP MCP Server as AgentCore Gateway Target\n", + "\n", + "## Overview\n", + "\n", + "This notebook walks through adding the [AWS for SAP MCP Server](https://docs.aws.amazon.com/mcp-sap/latest/awsforsapmcp/introduction.html) as an MCP target on [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html). The SAP MCP Server provides AI agents with structured, protocol-driven access to SAP S/4HANA and SAP ECC OData V2 services.\n", + "\n", + "| Information | Details |\n", + "|:---|:---|\n", + "| Target type | MCP Server Target |\n", + "| SAP MCP Server | [AWS for SAP MCP Server](https://docs.aws.amazon.com/mcp-sap/latest/awsforsapmcp/introduction.html) |\n", + "| Communication | Streamable HTTP (`/mcp` endpoint) |\n", + "| Outbound auth | CustomOauth2 (SAP MCP Server Cognito) |\n", + "| Inbound auth | Amazon Cognito (M2M) |\n", + "| Tools exposed | 9 (5 read + 4 write, read-only by default) |\n", + "| Default mode | Read-only (write operations disabled unless explicitly enabled) |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Prerequisites\n", + "\n", + "Before starting this notebook, you need:\n", + "\n", + "1. **AWS account** with access to Amazon Bedrock AgentCore\n", + "2. **AWS for SAP MCP Server** deployed \u2014 see [Getting Started](https://docs.aws.amazon.com/mcp-sap/latest/awsforsapmcp/getting-started.html)\n", + "3. **SAP MCP Server endpoint URL** and **Cognito credentials** for M2M authentication\n", + "4. **Python 3.11+** with Jupyter kernel\n", + "5. **AWS CLI** configured with appropriate credentials\n", + "\n", + "You can either create a new gateway here or reuse the one created in [01-salesforce-gateway-target.ipynb](01-salesforce-gateway-target.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "!pip install --force-reinstall -U -r requirements.txt --quiet" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import json\n", + "import logging\n", + "import os\n", + "import time\n", + "import uuid\n", + "\n", + "import boto3\n", + "import requests\n", + "from boto3.session import Session\n", + "\n", + "import gateway_mcp_client\n", + "\n", + "# AWS credentials \u2014 set your profile\n", + "os.environ[\"AWS_PROFILE\"] = \"default\" # Change to your profile\n", + "\n", + "logging.basicConfig(\n", + " level=logging.INFO,\n", + " format=\"%(asctime)s | %(levelname)s | %(name)s | %(message)s\",\n", + " handlers=[logging.StreamHandler()],\n", + ")\n", + "\n", + "session = Session()\n", + "REGION = session.region_name or \"eu-west-1\"\n", + "ACCOUNT_ID = boto3.client(\"sts\").get_caller_identity()[\"Account\"]\n", + "\n", + "# Derive Bedrock model ID geo prefix from region\n", + "GEO_PREFIX = {\"us-\": \"us\", \"eu-\": \"eu\", \"ap-\": \"ap\", \"ca-\": \"us\", \"sa-\": \"us\"}\n", + "MODEL_PREFIX = next((v for k, v in GEO_PREFIX.items() if REGION.startswith(k)), \"us\")\n", + "MODEL_ID = f\"{MODEL_PREFIX}.anthropic.claude-sonnet-4-6\"\n", + "\n", + "print(f\"Using region: {REGION}\")\n", + "print(f\"AWS account: {ACCOUNT_ID}\")\n", + "print(f\"Model ID: {MODEL_ID}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Collect SAP MCP Server credentials\n", + "SAP_MCP_ENDPOINT = input(\"Enter the SAP MCP Server endpoint URL (e.g., https:///mcp): \")\n", + "SAP_TOKEN_ENDPOINT = input(\"Enter the SAP MCP Server Cognito token endpoint: \")\n", + "SAP_CLIENT_ID = input(\"Enter the SAP MCP Server Client ID: \")\n", + "SAP_CLIENT_SECRET = getpass.getpass(\"Enter the SAP MCP Server Client Secret: \")\n", + "SAP_SCOPE = input(\"Enter the OAuth scope (e.g., /invoke): \")\n", + "\n", + "assert SAP_MCP_ENDPOINT.strip(), \"SAP MCP endpoint cannot be empty\"\n", + "assert SAP_TOKEN_ENDPOINT.strip(), \"Token endpoint cannot be empty\"\n", + "assert SAP_CLIENT_ID.strip(), \"Client ID cannot be empty\"\n", + "assert SAP_CLIENT_SECRET.strip(), \"Client Secret cannot be empty\"\n", + "\n", + "print(f\"\\nSAP MCP endpoint: {SAP_MCP_ENDPOINT}\")\n", + "print(f\"Token endpoint: {SAP_TOKEN_ENDPOINT}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## AWS for SAP MCP Server \u2014 Architecture\n\nThe [AWS for SAP MCP Server](https://docs.aws.amazon.com/mcp-sap/latest/awsforsapmcp/introduction.html) runs on Amazon Bedrock AgentCore Runtime and provides AI agents with access to SAP S/4HANA and SAP ECC OData V2 services.\n\n**Key characteristics:**\n- Communicates via **Streamable HTTP** on a `/mcp` endpoint\n- **Read-only by default** \u2014 write operations require explicit opt-in\n- Credentials are **never stored on disk** \u2014 retrieved at runtime from AWS Secrets Manager\n- Supports **Basic Auth** or **OAuth 2.0** for outbound SAP authentication\n- See [Getting Started](https://docs.aws.amazon.com/mcp-sap/latest/awsforsapmcp/getting-started.html) for deployment options\n\n### Available Tools\n\n| Tool | Category | Description |\n|---|---|---|\n| `find_sap_services` | Read | Discover available SAP OData services from the catalog |\n| `get_metadata` | Read | Retrieve OData service metadata (entity types, properties) |\n| `get_service_hints` | Read | Get usage guidance for specific services |\n| `odata_read` | Read | Read data from SAP OData entity sets with filtering |\n| `odata_count` | Read | Count records in an entity set (use before reads) |\n| `odata_create` | Write | Create new entity records (deep insert supported) |\n| `odata_update` | Write | Update existing entity records |\n| `odata_delete` | Write | Delete entity records |\n| `odata_function_import` | Write | Execute custom backend logic |\n\n> **Security:** Write tools are only registered when explicitly enabled in the SAP MCP Server configuration. Follow the principle of least privilege \u2014 only enable the specific write operations your use case requires." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Test SAP MCP Server authentication\n", + "sap_token = gateway_mcp_client.get_cognito_m2m_token(\n", + " token_endpoint=SAP_TOKEN_ENDPOINT,\n", + " client_id=SAP_CLIENT_ID,\n", + " client_secret=SAP_CLIENT_SECRET,\n", + " scope=SAP_SCOPE,\n", + ")\n", + "print(f\"\u2713 SAP MCP Server token obtained ({len(sap_token)} chars)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Step 2: Connect to Existing Gateway\n\nThis notebook adds the SAP target to the **same gateway** created in [01-salesforce-gateway-target.ipynb](01-salesforce-gateway-target.ipynb). Provide the Gateway ID from notebook 01." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Connect to the gateway created in notebook 01\ngateway_client = boto3.client(\"bedrock-agentcore-control\", region_name=REGION)\n\ntry:\n # If running in same kernel as NB01, GATEWAY_ID is already set\n _ = GATEWAY_ID\nexcept NameError:\n GATEWAY_ID = input(\"Enter Gateway ID from notebook 01: \").strip()\n\n# Look up all gateway details from the API\ngw = gateway_client.get_gateway(gatewayIdentifier=GATEWAY_ID)\nGATEWAY_URL = gw[\"gatewayUrl\"]\nGATEWAY_NAME = gw[\"name\"]\n\n# Get Cognito details from the authorizer config\nauthorizer = gw[\"authorizerConfiguration\"][\"customJWTAuthorizer\"]\nDISCOVERY_URL = authorizer[\"discoveryUrl\"]\nGW_CLIENT_ID = authorizer[\"allowedClients\"][0]\n\n# Derive token endpoint and scope from the discovery URL / gateway name\n# Discovery URL format: https://cognito-idp..amazonaws.com//.well-known/openid-configuration\npool_id = DISCOVERY_URL.split(\"/\")[3]\ncognito_client = boto3.client(\"cognito-idp\", region_name=REGION)\npool_clients = cognito_client.list_user_pool_clients(UserPoolId=pool_id, MaxResults=10)\nclient_info = cognito_client.describe_user_pool_client(\n UserPoolId=pool_id, ClientId=GW_CLIENT_ID\n)[\"UserPoolClient\"]\nGW_CLIENT_SECRET = client_info.get(\"ClientSecret\", \"\")\n\n# Get domain for token endpoint\npool_desc = cognito_client.describe_user_pool(UserPoolId=pool_id)[\"UserPool\"]\nCOGNITO_DOMAIN = pool_desc.get(\"Domain\", \"\")\nTOKEN_ENDPOINT = f\"https://{COGNITO_DOMAIN}.auth.{REGION}.amazoncognito.com/oauth2/token\"\n\n# Get scope from allowed scopes\nFULL_SCOPE = client_info.get(\"AllowedOAuthScopes\", [\"\"])[0]\n\nprint(f\"\u2713 Connected to gateway: {GATEWAY_NAME}\")\nprint(f\" Gateway ID: {GATEWAY_ID}\")\nprint(f\" Gateway URL: {GATEWAY_URL}\")\nprint(f\" Region: {REGION}\")" + }, + { + "cell_type": "markdown", + "source": "## Step 3: Create SAP Credential Provider\n\nRegister the SAP MCP Server's OAuth2 credentials with AgentCore Identity so the gateway can authenticate outbound requests.", + "metadata": {} + }, + { + "cell_type": "code", + "source": "SAP_CREDENTIAL_PROVIDER_NAME = f\"sap-mcp-oauth-{str(uuid.uuid4())[:8]}\"\n\ncred_resp = gateway_client.create_oauth2_credential_provider(\n name=SAP_CREDENTIAL_PROVIDER_NAME,\n credentialProviderVendor=\"CustomOauth2\",\n oauth2ProviderConfigInput={\n \"customOauth2ProviderConfig\": {\n \"clientId\": SAP_CLIENT_ID,\n \"clientSecret\": SAP_CLIENT_SECRET,\n \"oauthDiscovery\": {\n \"authorizationServerMetadata\": {\n \"issuer\": SAP_TOKEN_ENDPOINT.replace(\"/oauth2/token\", \"\"),\n \"authorizationEndpoint\": SAP_TOKEN_ENDPOINT.replace(\"/token\", \"/authorize\"),\n \"tokenEndpoint\": SAP_TOKEN_ENDPOINT,\n }\n },\n }\n },\n)\n\nSAP_CREDENTIAL_PROVIDER_ARN = cred_resp[\"credentialProviderArn\"]\nprint(f\"Created SAP credential provider: {SAP_CREDENTIAL_PROVIDER_NAME}\")\nprint(f\"ARN: {SAP_CREDENTIAL_PROVIDER_ARN}\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "source": "## Step 4: Add SAP MCP Server as Gateway Target\n\nWe add the SAP MCP Server as an MCP server target on the gateway. This tells the gateway to proxy MCP requests to the SAP MCP Server endpoint.", + "metadata": {} + }, + { + "cell_type": "code", + "metadata": {}, + "source": "SAP_TARGET_NAME = \"sap-target\"\n\ntarget_resp = gateway_client.create_gateway_target(\n gatewayIdentifier=GATEWAY_ID,\n name=SAP_TARGET_NAME,\n targetConfiguration={\n \"mcp\": {\n \"mcpServer\": {\n \"endpoint\": SAP_MCP_ENDPOINT,\n }\n }\n },\n credentialProviderConfigurations=[\n {\n \"credentialProviderType\": \"OAUTH\",\n \"credentialProvider\": {\n \"oauthCredentialProvider\": {\n \"providerArn\": SAP_CREDENTIAL_PROVIDER_ARN,\n \"scopes\": [],\n \"grantType\": \"CLIENT_CREDENTIALS\",\n }\n },\n }\n ],\n)\n\nSAP_TARGET_ID = target_resp[\"targetId\"]\nprint(f\"Created SAP target: {SAP_TARGET_NAME} ({SAP_TARGET_ID})\")\nprint(\"Waiting for target to reach READY status...\")", + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Wait for target to become READY\n", + "gateway_mcp_client.wait_for_target_ready(\n", + " client=gateway_client,\n", + " gateway_id=GATEWAY_ID,\n", + " target_name=SAP_TARGET_NAME,\n", + " region=REGION,\n", + " timeout=300,\n", + ")\n", + "print(\"\\n\u2713 SAP MCP Server target is READY\")" + ] + }, + { + "cell_type": "code", + "source": "# Set up gateway client for tool invocation\ndef get_gw_token() -> str:\n return gateway_mcp_client.get_cognito_m2m_token(\n token_endpoint=TOKEN_ENDPOINT,\n client_id=GW_CLIENT_ID,\n client_secret=GW_CLIENT_SECRET,\n scope=FULL_SCOPE,\n )\n\nmcp = gateway_mcp_client.GatewayMCPClient(\n gateway_url=GATEWAY_URL,\n get_token=get_gw_token,\n session_id=str(uuid.uuid4()),\n)\n\n# Verify tools are accessible\nall_tools = mcp.list_all_tools()\nsap_tools = [t for t in all_tools if t[\"name\"].startswith(\"sap-target___\")]\nprint(f\"Total tools on gateway: {len(all_tools)}\")\nprint(f\"SAP tools: {len(sap_tools)}\")\nprint(\"\\nSAP tool names:\")\nfor t in sorted(sap_tools, key=lambda x: x[\"name\"]):\n print(f\" - {t['name']}\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Step 5: Query SAP Data via Gateway\n\nNow that the SAP target is ready, let's read data from SAP through the gateway." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Query 1: Show the first 3 business partners\nresult = mcp.call_tool(\n \"sap-target___odata_read\",\n {\n \"service_name\": \"API_BUSINESS_PARTNER\",\n \"entity_set\": \"A_BusinessPartner\",\n \"top\": 3,\n \"select\": \"BusinessPartner,BusinessPartnerFullName,BusinessPartnerCategory\",\n },\n)\nprint(\"=== First 3 SAP Business Partners ===\")\ncontent = result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n try:\n data = json.loads(item[\"text\"])\n for bp in data.get(\"data\", data.get(\"results\", [])):\n print(f\" {bp.get('BusinessPartner', '?'):>6} {bp.get('BusinessPartnerFullName', '?')}\")\n except json.JSONDecodeError:\n print(item[\"text\"][:500])" + }, + { + "cell_type": "code", + "metadata": {}, + "source": "# Query 2: Count how many business partners exist\nresult = mcp.call_tool(\n \"sap-target___odata_count\",\n {\n \"service_name\": \"API_BUSINESS_PARTNER\",\n \"entity_set\": \"A_BusinessPartner\",\n },\n)\nprint(\"=== Business Partner Count ===\")\ncontent = result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n print(f\" Total business partners in SAP: {item['text']}\")", + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "## Step 6: Use Strands Agent with SAP Tools\n\nLet a Strands Agent answer a business question using SAP data." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from strands import Agent\nfrom strands.tools.mcp import MCPClient\nfrom mcp.client.streamable_http import streamablehttp_client\n\nmcp_client = MCPClient(\n lambda: streamablehttp_client(\n url=GATEWAY_URL,\n headers={\n \"Authorization\": f\"Bearer {get_gw_token()}\",\n \"MCP-Protocol-Version\": \"2025-03-26\",\n },\n )\n)\n\nSYSTEM_PROMPT = (\n \"You are a helpful assistant with access to SAP ERP data. \"\n \"Use odata_read to query data. Keep answers concise.\"\n)\n\nwith mcp_client:\n agent = Agent(\n model=MODEL_ID,\n system_prompt=SYSTEM_PROMPT,\n tools=mcp_client.list_tools_sync(),\n )\n result = agent(\n \"Show me the first 3 sales orders from SAP with their amounts and currencies.\"\n )\n print(result)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Best Practices\n", + "\n", + "From the [AWS for SAP MCP Server documentation](https://docs.aws.amazon.com/mcp-sap/latest/awsforsapmcp/security.html):\n", + "\n", + "1. **Read-only by default** \u2014 Write tools are disabled unless both global and per-operation flags are set to `true`. Only enable writes your use case requires.\n", + "2. **Principle of least privilege** \u2014 Assign the SAP user only the minimum necessary roles and authorizations.\n", + "3. **Scope OAuth access** \u2014 Configure OAuth scopes to grant access only to specific required OData services.\n", + "4. **Use odata_count before reads** \u2014 Understand data volume before issuing broad reads to avoid overwhelming the agent.\n", + "5. **Use get_metadata proactively** \u2014 SAP field names are non-obvious (e.g., `OverallOrdReltdBillgStatus` not `OverallBillingStatus`). Always check metadata before constructing filters.\n", + "6. **Credentials never stored on disk** \u2014 The MCP Server retrieves credentials at runtime from AWS Secrets Manager." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 8: Clean Up\n", + "\n", + "Delete resources created in this notebook. Skip if you plan to continue with [03-cross-isv-queries.ipynb](03-cross-isv-queries.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "SKIP_CLEANUP = input(\"Skip cleanup to use gateway in notebook 03? (yes/no): \").strip().lower() == \"yes\"\n\nif not SKIP_CLEANUP:\n # Delete SAP gateway target\n print(\"Deleting SAP gateway target...\")\n gateway_client.delete_gateway_target(gatewayIdentifier=GATEWAY_ID, targetId=SAP_TARGET_ID)\n time.sleep(5)\n print(\" \u2713 SAP target deleted\")\n\n # Delete SAP credential provider\n print(\"Deleting SAP credential provider...\")\n gateway_client.delete_oauth2_credential_provider(name=SAP_CREDENTIAL_PROVIDER_NAME)\n print(\" \u2713 Credential provider deleted\")\n\n if CREATED_GATEWAY:\n # Delete gateway\n print(\"Deleting gateway...\")\n gateway_client.delete_gateway(gatewayIdentifier=GATEWAY_ID)\n time.sleep(5)\n print(\" \u2713 Gateway deleted\")\n\n # Delete Cognito resources\n print(\"Deleting Cognito resources...\")\n cognito_client.delete_user_pool_domain(Domain=COGNITO_DOMAIN, UserPoolId=USER_POOL_ID)\n cognito_client.delete_user_pool(UserPoolId=USER_POOL_ID)\n print(\" \u2713 Cognito pool deleted\")\n\n # Delete IAM role (must remove inline policy first)\n print(\"Deleting IAM role...\")\n iam.delete_role_policy(RoleName=ROLE_NAME, PolicyName=\"AgentCorePolicy\")\n iam.delete_role(RoleName=ROLE_NAME)\n print(\" \u2713 IAM role deleted\")\n\n print(\"\\n\u2713 All resources cleaned up\")\nelse:\n print(\"\\nSkipping cleanup. Remember to clean up resources when done!\")\n print(f\" Gateway ID: {GATEWAY_ID}\")\n print(f\" Gateway URL: {GATEWAY_URL}\")\n print(f\" SAP Target ID: {SAP_TARGET_ID}\")" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/03-cross-isv-queries.ipynb b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/03-cross-isv-queries.ipynb new file mode 100644 index 000000000..fd5cd98f2 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/03-cross-isv-queries.ipynb @@ -0,0 +1,264 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cross-ISV Queries: Salesforce + SAP via Single Gateway\n", + "\n", + "## Overview\n", + "\n", + "This notebook demonstrates the power of routing multiple ISV platforms through a single [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html). With Salesforce (CRM) and SAP (ERP) connected to the same gateway, an AI agent can answer questions that span both systems. The user does not need to know which system holds which data.\n", + "\n", + "**Use cases demonstrated:**\n", + "1. Customer 360 \u2014 combine SAP business partner data with Salesforce account history\n", + "2. Pipeline Reconciliation \u2014 compare Salesforce opportunities with SAP sales orders\n", + "3. Support Case with ERP Context \u2014 enrich Salesforce cases with SAP inventory data\n", + "4. Natural Language Agent \u2014 let Claude orchestrate cross-system queries autonomously\n", + "\n", + "| Information | Details |\n", + "|:---|:---|\n", + "| Prerequisites | Both Salesforce + SAP targets READY on same gateway (from notebooks 01 + 02) |\n", + "| Tools available | ~52 (43 Salesforce + 9 SAP) |\n", + "| Agent framework | Strands Agents |\n", + "| LLM | Anthropic Claude Sonnet 4.6 (`us.anthropic.claude-sonnet-4-6`) |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Prerequisites\n", + "\n", + "This notebook assumes you have completed:\n", + "1. [01-salesforce-gateway-target.ipynb](01-salesforce-gateway-target.ipynb) \u2014 Salesforce target is READY\n", + "2. [02-sap-mcp-server-target.ipynb](02-sap-mcp-server-target.ipynb) \u2014 SAP target is READY\n", + "\n", + "Both targets must be on the **same gateway**. You'll provide the gateway connection details below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "!pip install --force-reinstall -U -r requirements.txt --quiet" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import json\n", + "import logging\n", + "import os\n", + "import uuid\n", + "\n", + "import boto3\n", + "import requests\n", + "from boto3.session import Session\n", + "\n", + "import gateway_mcp_client\n", + "\n", + "# AWS credentials \u2014 set your profile\n", + "os.environ[\"AWS_PROFILE\"] = \"default\" # Change to your profile\n", + "\n", + "logging.basicConfig(\n", + " level=logging.INFO,\n", + " format=\"%(asctime)s | %(levelname)s | %(name)s | %(message)s\",\n", + " handlers=[logging.StreamHandler()],\n", + ")\n", + "\n", + "session = Session()\n", + "REGION = session.region_name or \"eu-west-1\"\n", + "\n", + "# Derive Bedrock model ID geo prefix from region\n", + "GEO_PREFIX = {\"us-\": \"us\", \"eu-\": \"eu\", \"ap-\": \"ap\", \"ca-\": \"us\", \"sa-\": \"us\"}\n", + "MODEL_PREFIX = next((v for k, v in GEO_PREFIX.items() if REGION.startswith(k)), \"us\")\n", + "MODEL_ID = f\"{MODEL_PREFIX}.anthropic.claude-sonnet-4-6\"\n", + "\n", + "print(f\"Using region: {REGION}\")\n", + "print(f\"Model ID: {MODEL_ID}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Connect to the gateway (auto-detect from kernel or enter ID)\ngateway_client = boto3.client(\"bedrock-agentcore-control\", region_name=REGION)\n\ntry:\n _ = GATEWAY_ID\nexcept NameError:\n GATEWAY_ID = input(\"Enter Gateway ID from notebook 01: \").strip()\n\n# Look up all gateway details from the API\ngw = gateway_client.get_gateway(gatewayIdentifier=GATEWAY_ID)\nGATEWAY_URL = gw[\"gatewayUrl\"]\nGATEWAY_NAME = gw[\"name\"]\n\n# Get Cognito details from the authorizer config\nauthorizer = gw[\"authorizerConfiguration\"][\"customJWTAuthorizer\"]\nDISCOVERY_URL = authorizer[\"discoveryUrl\"]\nGW_CLIENT_ID = authorizer[\"allowedClients\"][0]\n\npool_id = DISCOVERY_URL.split(\"/\")[3]\ncognito_client = boto3.client(\"cognito-idp\", region_name=REGION)\nclient_info = cognito_client.describe_user_pool_client(\n UserPoolId=pool_id, ClientId=GW_CLIENT_ID\n)[\"UserPoolClient\"]\nGW_CLIENT_SECRET = client_info.get(\"ClientSecret\", \"\")\n\npool_desc = cognito_client.describe_user_pool(UserPoolId=pool_id)[\"UserPool\"]\nCOGNITO_DOMAIN = pool_desc.get(\"Domain\", \"\")\nTOKEN_ENDPOINT = f\"https://{COGNITO_DOMAIN}.auth.{REGION}.amazoncognito.com/oauth2/token\"\nFULL_SCOPE = client_info.get(\"AllowedOAuthScopes\", [\"\"])[0]\n\n# Salesforce domain (needed for SF tool calls)\ntry:\n _ = SF_DOMAIN\nexcept NameError:\n SF_DOMAIN = input(\"Enter Salesforce domain (e.g., myorg-dev-ed): \").strip()\n\nprint(f\"\u2713 Connected to gateway: {GATEWAY_NAME}\")\nprint(f\" Gateway URL: {GATEWAY_URL}\")\nprint(f\" SF domain: {SF_DOMAIN}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Connect to the gateway\n", + "def get_gw_token() -> str:\n", + " return gateway_mcp_client.get_cognito_m2m_token(\n", + " token_endpoint=TOKEN_ENDPOINT,\n", + " client_id=GW_CLIENT_ID,\n", + " client_secret=GW_CLIENT_SECRET,\n", + " scope=FULL_SCOPE,\n", + " )\n", + "\n", + "mcp = gateway_mcp_client.GatewayMCPClient(\n", + " gateway_url=GATEWAY_URL,\n", + " get_token=get_gw_token,\n", + " session_id=str(uuid.uuid4()),\n", + ")\n", + "\n", + "# List all tools from both targets\n", + "all_tools = mcp.list_all_tools()\n", + "sf_tools = [t for t in all_tools if t[\"name\"].startswith(\"salesforce-target___\")]\n", + "sap_tools = [t for t in all_tools if t[\"name\"].startswith(\"sap-target___\")]\n", + "\n", + "print(f\"Total tools: {len(all_tools)}\")\n", + "print(f\" Salesforce: {len(sf_tools)}\")\n", + "print(f\" SAP: {len(sap_tools)}\")\n", + "print(f\" Other: {len(all_tools) - len(sf_tools) - len(sap_tools)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Use Case 1: Customer 360\n", + "\n", + "Combine customer data from both SAP (business partner master data) and Salesforce (CRM account history) to build a unified view.\n", + "\n", + "**Pattern:** Query SAP for business partner details \u2192 Query Salesforce for account + opportunities" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# SAP: Get business partner data\nsap_result = mcp.call_tool(\n \"sap-target___odata_read\",\n {\n \"service_name\": \"API_BUSINESS_PARTNER\",\n \"entity_set\": \"A_BusinessPartner\",\n \"top\": 5,\n \"select\": \"BusinessPartner,BusinessPartnerFullName,BusinessPartnerCategory\",\n },\n)\n\nprint(\"=== SAP Business Partners ===\")\ncontent = sap_result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n for bp in data.get(\"data\", []):\n print(f\" {bp.get('BusinessPartner', '?'):>6} {bp.get('BusinessPartnerFullName', '?'):40s} Cat: {bp.get('BusinessPartnerCategory', '?')}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Salesforce: Get accounts\nsf_result = mcp.call_tool(\n \"salesforce-target___queryAccounts\",\n {\n \"domainName\": SF_DOMAIN,\n \"q\": \"SELECT Id, Name, Industry, CreatedDate FROM Account LIMIT 5\",\n },\n)\n\nprint(\"=== Salesforce Accounts ===\")\ncontent = sf_result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n for rec in data.get(\"records\", []):\n print(f\" {rec.get('Name', '?'):40s} Industry: {rec.get('Industry', 'N/A')}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Use Case 2: Pipeline Reconciliation\n", + "\n", + "Compare Salesforce opportunities (sales pipeline) with SAP sales orders to identify which deals have been converted to orders.\n", + "\n", + "**Pattern:** Query Salesforce for open opportunities \u2192 Query SAP for matching sales orders" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Salesforce: Get open opportunities\nsf_opps = mcp.call_tool(\n \"salesforce-target___queryAccounts\",\n {\n \"domainName\": SF_DOMAIN,\n \"q\": \"SELECT Id, Name, Amount, StageName, CloseDate FROM Opportunity WHERE IsClosed = false LIMIT 5\",\n },\n)\n\nprint(\"=== Salesforce Open Opportunities ===\")\ncontent = sf_opps.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n for rec in data.get(\"records\", []):\n amt = rec.get(\"Amount\", \"N/A\")\n print(f\" {rec.get('Name', '?'):40s} Stage: {rec.get('StageName', '?'):15s} Amount: {amt}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# SAP: Get recent sales orders\nsap_orders = mcp.call_tool(\n \"sap-target___odata_read\",\n {\n \"service_name\": \"API_SALES_ORDER_SRV\",\n \"entity_set\": \"A_SalesOrder\",\n \"top\": 5,\n \"select\": \"SalesOrder,SoldToParty,TotalNetAmount,TransactionCurrency,CreationDate\",\n },\n)\n\nprint(\"=== SAP Sales Orders ===\")\ncontent = sap_orders.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n for order in data.get(\"data\", []):\n print(f\" Order {order.get('SalesOrder', '?'):>10} Customer: {order.get('SoldToParty', '?'):>10} Amount: {order.get('TotalNetAmount', '?')} {order.get('TransactionCurrency', '')}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Use Case 3: Support Case with ERP Context\n", + "\n", + "When creating a support case in Salesforce, enrich it with relevant SAP data (e.g., material stock levels, order history).\n", + "\n", + "**Pattern:** Query SAP for relevant data \u2192 Create enriched Salesforce case" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# SAP: Check available services for material/inventory\nsap_services = mcp.call_tool(\n \"sap-target___get_metadata\",\n {\"service_name\": \"API_MATERIAL_STOCK_SRV\"},\n)\n\nprint(\"=== SAP Material Stock Service Metadata ===\")\ncontent = sap_services.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n print(item[\"text\"][:2000])" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Salesforce: Create a case with ERP context\n# Content-Type is a restricted header managed by the gateway \u2014 pass \"\" to avoid duplication\ncase_result = mcp.call_tool(\n \"salesforce-target___createCase\",\n {\n \"domainName\": SF_DOMAIN,\n \"Content-Type\": \"\",\n \"Subject\": \"Stock Discrepancy - Cross-System Investigation\",\n \"Description\": \"Customer reported inventory mismatch. SAP material stock checked via AgentCore Gateway.\",\n \"Status\": \"New\",\n \"Priority\": \"Medium\",\n \"Origin\": \"Web\",\n },\n)\n\nprint(\"=== Created Salesforce Case ===\")\ncontent = case_result.get(\"result\", {}).get(\"content\", [])\nfor item in content:\n if item.get(\"type\") == \"text\":\n data = json.loads(item[\"text\"])\n if data.get(\"success\"):\n print(f\" \u2713 Case created: {data['id']}\")\n else:\n print(f\" \u2717 Failed: {data.get('errors', data)}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Use Case 4: Natural Language Agent (Cross-ISV)\n\nLet a Strands Agent handle cross-system queries autonomously using all tools from both targets." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from strands import Agent, tool\n\n# Suppress verbose logging\nlogging.getLogger(\"httpx\").setLevel(logging.WARNING)\nlogging.getLogger(\"strands\").setLevel(logging.WARNING)\n\n# Create a generic gateway tool that the agent can call with any tool name\n@tool\ndef gateway_tool_call(tool_name: str, arguments: str) -> str:\n \"\"\"Call any tool on the AgentCore Gateway by name. Pass arguments as a JSON string.\"\"\"\n args = json.loads(arguments)\n result = mcp.call_tool(tool_name, args)\n content = result.get(\"result\", {}).get(\"content\", [])\n for item in content:\n if item.get(\"type\") == \"text\":\n return item[\"text\"]\n return json.dumps(result)\n\n# Build a tool list description for the system prompt\ntool_list = \"\\n\".join(f\" - {t['name']}: {t.get('description', '')}\" for t in all_tools)\n\nSYSTEM_PROMPT = (\n \"You are a helpful enterprise assistant. You can call any of these tools via gateway_tool_call:\\n\\n\"\n f\"{tool_list}\\n\\n\"\n f\"For Salesforce tools, always include domainName='{SF_DOMAIN}' in arguments. \"\n \"For SAP tools, use service_name and entity_set parameters. \"\n \"Keep answers concise and tabular.\"\n)\n\nagent = Agent(\n model=MODEL_ID,\n system_prompt=SYSTEM_PROMPT,\n tools=[gateway_tool_call],\n)\n\nresult = agent(\n \"Get the first 3 SAP business partners (use sap-target___odata_read with \"\n \"service_name=API_BUSINESS_PARTNER, entity_set=A_BusinessPartner, top=3, \"\n \"select=BusinessPartner,BusinessPartnerFullName) \"\n f\"and the first 3 Salesforce accounts (use salesforce-target___queryAccounts with \"\n f\"domainName={SF_DOMAIN}, q=SELECT Id,Name FROM Account LIMIT 3). \"\n \"Then compare which names exist in both systems.\"\n)\nprint(result)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Use Case | Salesforce Tools | SAP Tools | Value |\n", + "|---|---|---|---|\n", + "| Customer 360 | queryAccounts, getAccountById | odata_read (A_BusinessPartner) | Unified customer view across CRM + ERP |\n", + "| Pipeline Reconciliation | queryAccounts (Opportunities) | odata_read (A_SalesOrder) | Track deal-to-order conversion |\n", + "| Support + ERP Context | createCase | find_sap_services, odata_read | Enriched support tickets |\n", + "| Natural Language Agent | All 43 SF tools | All 9 SAP tools | AI-driven cross-system intelligence |\n", + "\n", + "**Key takeaway:** A single AgentCore Gateway endpoint consolidates access to multiple ISV platforms, enabling AI agents to answer cross-system questions without custom integration code." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clean Up\n", + "\n", + "If you're done with all three notebooks, clean up the gateway and related resources. Refer to the cleanup cells in [01-salesforce-gateway-target.ipynb](01-salesforce-gateway-target.ipynb) and [02-sap-mcp-server-target.ipynb](02-sap-mcp-server-target.ipynb) for the full deletion sequence:\n", + "\n", + "1. Delete gateway targets (both Salesforce and SAP)\n", + "2. Delete credential providers\n", + "3. Delete the gateway\n", + "4. Delete Cognito resources (domain, then user pool)\n", + "5. Delete IAM role" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/README.md b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/README.md new file mode 100644 index 000000000..1c3ddb04e --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/README.md @@ -0,0 +1,74 @@ + + + +# 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. diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/diagrams.py b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/diagrams.py new file mode 100644 index 000000000..fcd177e02 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/diagrams.py @@ -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/") diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/gateway_mcp_client.py b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/gateway_mcp_client.py new file mode 100644 index 000000000..dcbb1876a --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/gateway_mcp_client.py @@ -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") diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/icons/agentcore-runtime.png b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/icons/agentcore-runtime.png new file mode 100644 index 000000000..6a06c3ca0 Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/icons/agentcore-runtime.png differ diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/images/multi-isv-architecture.png b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/images/multi-isv-architecture.png new file mode 100644 index 000000000..ef6f7fe76 Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/images/multi-isv-architecture.png differ diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/requirements.txt b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/requirements.txt new file mode 100644 index 000000000..066c5628f --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/requirements.txt @@ -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 diff --git a/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/ruff.toml b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/ruff.toml new file mode 100644 index 000000000..570f04970 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/19-multi-isv-orchestration/ruff.toml @@ -0,0 +1,2 @@ +line-length = 120 +target-version = "py311"