I tried connecting GitHub, Slack, and Google Calendar to an AI agent using user-delegated authorization (3LO) with Amazon Bedrock AgentCore Gateway

I tried connecting GitHub, Slack, and Google Calendar to an AI agent using user-delegated authorization (3LO) with Amazon Bedrock AgentCore Gateway

I configured user-delegated authorization (3LO) in Amazon Bedrock AgentCore Gateway and made it possible to use GitHub, Slack, and Google Calendar from an AI agent hosted on AgentCore Runtime!
2026.07.29

This page has been translated by machine translation. View original

Introduction

Hello, I'm Jinno from the Consulting Department, and I love supermarkets.
It's the height of summer and it's really hot, isn't it? I've been into buying cheap ice cream at the supermarket to beat the heat.

Completely changing the subject, this article is the 6th entry in the 'Summer Vacation Independent Research Relay' by volunteers at Classmethod! Since this initiative is not just a simple "let's try it," but rather about building out systems, verifying undocumented behavior, and digging into why certain design decisions were made, I put in serious effort this time!

Motivation

Previously, I wrote an article about implementing user-delegated authorization (hereafter 3LO) with Amazon Bedrock AgentCore Identity to access Google Drive from an agent.

https://dev.classmethod.jp/articles/amazon-bedrock-agentcore-identity-3lo-google-drive/

In the previous version, while the agent itself ran on AgentCore Runtime, the server receiving the OAuth callback was a FastAPI instance on a local PC, and the token used for Session Binding was passed via environment variables. Since I prioritized understanding the mechanism and mixed in local components, it wasn't very practical...

At the end of the previous article, I wrote that I wanted to implement the entire authentication flow seamlessly in the cloud, so this time I'm collecting on that homework (in the spirit of summer vacation)!!

To make everything from login to authorization complete by passing a single URL, I quickly hosted the frontend with Amplify, and this time I got greedy and connected 3 services — GitHub, Slack, and Google Calendar — to a single Gateway, configured as outbound authentication using AgentCore Gateway's user-delegated type!

I think 3LO combined with Gateway cases can be hard to approach, so I hope this can serve as a sample for everyone.

The complete code is published on GitHub. This article omits some details for explanation purposes, so please refer to it as needed!

https://github.com/yuu551/bedrock-agentcore-3lo-gateway

Recap from Last Time (The Players in 3LO)

I'll defer the details to the previous article, but let me briefly recap the players involved in implementing 3LO with AgentCore Identity. This time, Gateway is added to the mix.

Term Role
Credential Provider Connection settings for external services. Registers client IDs and secrets
Token Vault A vault that securely stores user access tokens. Distinguishes tokens by the combination of user × workload × Provider
Session Binding The process of associating the completed OAuth authorization with a specific user in the Token Vault
Gateway An entry point that converts existing APIs and MCP servers into MCP tools for agents. Can configure 3LO as outbound authentication

The key point in the 3LO flow is Session Binding. When a user completes authorization on the external service side, they are redirected from AgentCore Identity to the application's callback screen with a session_id. Unless this session_id is linked to which user's authorization it was, the token won't be saved in the Token Vault, and the agent will never be able to retrieve the token.

Last time, this linking was done by a local FastAPI server, but this time I'll make it serverless using API Gateway + Lambda + DynamoDB.

This Time's Architecture

When the overall picture is drawn as a diagram, the configuration looks like this.

Architecture Diagram

Component Service Used Role
Frontend AWS Amplify Hosting (React SPA) Chat UI, OAuth callback screen, pre-linking settings panel
User Authentication Amazon Cognito (Amplify Auth) Login to the app. Access tokens are also used for Runtime and Gateway calls
Agent AgentCore Runtime + Strands Agents Agent that calls MCP tools on Gateway
Tool Infrastructure AgentCore Gateway Relays 3 targets and grants tokens with outbound authentication (3LO)
Conversation Memory AgentCore Memory Retains conversation history per conversation UUID
Authorization Infrastructure AgentCore Identity Credential Provider and Token Vault per service
Session Binding API API Gateway + Lambda + DynamoDB Records authorization flow and completes Session Binding

The Gateway targets are the following 3:

Target Connection Method Destination
GitHub MCP Server Target (static tool schema) GitHub official remote MCP server
Slack MCP Server Target (static tool schema) Slack official remote MCP server
Google Calendar OpenAPI Target Google Calendar API (REST)

You might be thinking: doesn't AgentCore Gateway have a built-in template for Slack Web?
Indeed, you can easily add targets from the console using built-in templates.

However, at the time of writing, there are no templates for GitHub and Google Calendar, and the outbound authentication for the Slack template is documented as API key method.

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-integrations.html

In the spirit of independent research, I wanted to align all 3 services with 3LO per user and also verify connectivity with official MCP servers for GitHub and Slack, so I chose the MCP Server Target for Slack instead of the template!

The processing flow as a sequence diagram looks like this. The flow is the same for any service — the first tool call results in an authorization URL being returned (URL elicitation), and after user authorization and Session Binding, the process resumes on retry.

That's quite a long sequence, enough to make you groan...
Let me break it down!

Authorization is independent per service. Ask a question about GitHub and a GitHub authorization link appears; ask about the calendar and a Google authorization link appears. Once each is authorized, the token is stored in the Token Vault, and from then on answers come back immediately without any authorization.

Also, the end user's JWT is passed directly from the agent to Gateway. Since user identification in the Token Vault is based on the inbound JWT to Gateway, using the agent's own M2M token here would cause all users' tokens to be associated with a single ID, making user delegation difficult.

Token retrieval, storage, and granting are all handled between Gateway and Token Vault, so the agent code and frontend never deal with external service tokens. I'll dig into this a bit more in the considerations section later.

Prerequisites

The environment for this time is as follows.

Item Value
Region us-east-1 (N. Virginia)
Python 3.12
Node.js 24.x
Package Management uv (Python) / pnpm (Node.js)
Agent Framework Strands Agents 1.45.0 + MCP Python SDK 1.28.1
AgentCore SDK bedrock-agentcore 1.15.1 or later
Model Claude Haiku 4.5
Frontend React (Vite) + AWS Amplify Gen 2
IaC aws-cdk-lib 2.261.0 or later (using aws_bedrockagentcore module)

This time the purpose is verification, and since the main focus is the connection between the agent and tools, I chose Haiku.

As prerequisites, you'll need a GitHub account, a Slack workspace where you have permission to install Apps, and a GCP project where you can create OAuth clients.

Setup

The overall flow is 5 steps. I'll leave the agent and frontend code to the repository and focus on 3LO and the target configuration!

  1. Create OAuth apps on each service side
  2. Register client secrets in Secrets Manager
  3. Implement the agent (3LO authorization hook + Memory integration)
  4. Define Gateway, targets, and Memory in backend.ts
  5. Deploy and register callback URLs

Create OAuth Apps on Each Service Side

GitHub OAuth App

Create from GitHub's Settings → Developer settings → OAuth Apps.

A placeholder URL is fine for the Authorization callback URL (you'll replace it in Step 5). After creation, note down the Client ID and Client Secret.

Slack App

Create from scratch at https://api.slack.com/apps.
Note that Slack's MCP server feature is only available for Marketplace-published apps or internal apps. This time I'm creating it as an internal app for my own workspace.

  1. Add channels:history / channels:read / search:read.public / users:read to User Token Scopes (not Bot Token Scopes) under OAuth & Permissions
  2. Add only users:read to Bot Token Scopes as well to create a bot user
  3. Enable MCP server access on the App Assistant page in app settings
  4. Note the Client ID and Client Secret from Basic Information

The bot user in Step 2 isn't actually used. It's not listed as required in the official documentation, but in my verification environment (as of July 2026), without a bot user the authorization screen showed a "doesn't have a bot user to install" error. If you encounter the same error, check Step 2.

Google OAuth Client

Enable the Google Calendar API in the GCP console and configure the OAuth consent screen.
Create an OAuth client of the web application type from the credentials page, and note the Client ID and Client Secret.

https://dev.classmethod.jp/articles/amazon-bedrock-agentcore-identity-3lo-google-drive/

Register Client Secrets in Secrets Manager

Since Credential Providers are created with CDK, we register secrets in Secrets Manager first to avoid hardcoding them in code.
Register all 3 services in the same format.

command
aws secretsmanager create-secret \
  --name github-agent/oauth-client-secret \
  --secret-string '{"client_secret": "<GitHub Client Secret>"}' \
  --region us-east-1
# Do the same for slack-agent/oauth-client-secret and google-agent/oauth-client-secret

The Credential Provider configuration references this secret as an EXTERNAL source.

Implement the Agent (3LO Authorization Hook + Memory Integration)

From the agent's perspective, Gateway behaves as an MCP server with Bearer authentication. It's just standard Strands MCP integration — simply connect to the Gateway with the user's JWT forwarded from Runtime!

agent/main.py (excerpt)
gateway = MCPClient(
    lambda: streamablehttp_client(
        GATEWAY_URL, headers={"Authorization": f"Bearer {bearer_token}"}
    )
)

with gateway:
    tools = gateway.list_tools_sync()

    agent = Agent(
        model=MODEL_ID,
        tools=tools,
        system_prompt=SYSTEM_PROMPT,
        hooks=[GatewayAuthHook(event_queue)],
        session_manager=session_manager,
        agent_id="default",
    )

However, 3LO requires a bit of extra work around waiting for authorization.

When a tool is called while the user's token is not yet in the Token Vault on the first request, Gateway returns a JSON-RPC error (code -32042), with the authorization URL in its data. On the application side, the task is to notify the frontend of this authorization URL and retry until authorization is complete.

I use Strands Hooks for this post-processing that spans all tools, where AfterToolCallEvent is called every time a tool finishes executing, and event.retry = True instructs the same tool to be re-executed.

agent/gateway_auth.py (excerpt)
class GatewayAuthHook(HookProvider):

    def __init__(self, event_queue: asyncio.Queue):
        self._event_queue = event_queue
        self._notified_providers: set[str] = set()
        self._deadlines: dict[str, float] = {}

    def register_hooks(self, registry: HookRegistry) -> None:
        registry.add_callback(AfterToolCallEvent, self._on_after_tool_call)

    async def _on_after_tool_call(self, event: AfterToolCallEvent) -> None:
        tool_name = event.tool_use.get("name", "") if event.tool_use else ""
        provider = provider_from_tool_name(tool_name)
        auth_url = extract_auth_url(event.result)

        if auth_url is None:
            # If a tool for a provider that was awaiting authorization succeeds, notify of connection completion
            if (
                provider
                and provider in self._notified_providers
                and _result_status(event.result) == "success"
            ):
                await self._event_queue.put({
                    "type": "connection_status",
                    "provider": provider,
                    "status": "connected",
                })
                self._deadlines.pop(provider, None)
            return

        # Proceed with authorization even if provider cannot be identified
        key = provider or f"unknown:{tool_name}"

        if key not in self._deadlines:
            self._deadlines[key] = time.monotonic() + AUTH_DEADLINE_SECONDS
        if time.monotonic() > self._deadlines[key]:
            # Timeout: notify error and stop retrying
            error_event = {
                "type": "error",
                "scope": "chat",
                "code": "authorization_timeout",
                "data": "Authorization wait time exceeded. Please try again.",
            }
            if provider:
                error_event["provider"] = provider
            await self._event_queue.put(error_event)
            self._deadlines.pop(key, None)
            return

        if key not in self._notified_providers:
            payload = {
                "type": "auth_required",
                "auth_url": auth_url,
            }
            if provider:
                payload["provider"] = provider
            await self._event_queue.put(payload)
            self._notified_providers.add(key)

        await asyncio.sleep(AUTH_POLL_INTERVAL)
        event.retry = True  # Discard result and re-execute the same tool

The provider is identified from the tool name prefix (githubmcp___ / slackmcp___ / googlecal___), and authorization URL notification, connection completion notification (connection_status: connected), and a 5-minute deadline timeout are all handled in a common way across providers. There's no need to implement OAuth processing per service in the agent.

When adding a service, the only things to configure individually are the prefix mapping table for the connection panel display, the read tool used for connection verification, and the system prompt.

This 5-second interval polling is not ideal — if there were a push-type API to notify when a token is stored in the Token Vault, retries could happen immediately. But since that doesn't currently exist, even after authorization completes there's a wait of up to 5 seconds until the next poll... so the experience is a bit sluggish.

Session Binding API (Lambda + DynamoDB)

The concept is the same as the previous FastAPI version, but this time I made it serverless with Lambda + DynamoDB!
The API consists of two endpoints.

amplify/functions/session-binding/handler.ts (excerpt)
// session URI is equivalent to Bearer, so only the SHA-256 hash is stored in the table
const flowKeyOf = (value: string) =>
  createHash('sha256').update(value).digest('hex');

// POST /auth/pending: Register a PENDING record using the authorization URL's request_uri as a flow identifier
if (event.rawPath === '/auth/pending') {
  const { flow_id: flowId, provider } = JSON.parse(event.body ?? '{}');
  if (typeof flowId !== 'string' || flowId.length === 0 || flowId.length > 2048) {
    return json(400, { error: 'flow_id is required' });
  }

  const hashedFlowId = flowKeyOf(flowId);
  try {
    await ddb.send(
      new PutCommand({
        TableName: TABLE_NAME,
        Item: {
          userId,
          flowId: hashedFlowId,
          ...(typeof provider === 'string' ? { provider } : {}),
          status: 'PENDING',
          createdAt: new Date().toISOString(),
          ttl: Math.floor(Date.now() / 1000) + 900, // Expires in 15 minutes
        },
        // Do not allow overwriting a COMPLETED record back to PENDING to prevent double Binding
        ConditionExpression:
          'attribute_not_exists(userId) AND attribute_not_exists(flowId)',
      })
    );
  } catch (e) {
    if (!isConditionalFailure(e)) throw e;

    // Check existing record: return 200 if within deadline and PENDING,
    // return 409 if already completed or expired (see implementation for details)
  }
  return json(200, { status: 'ok' });
}

// POST /auth/complete: Link session_id with user after callback
if (event.rawPath === '/auth/complete') {
  const { session_id: sessionId } = JSON.parse(event.body ?? '{}');

  // The callback's session_id is the same URN as the authorization URL's request_uri,
  // so only the record with a matching hash can be completed as a started flow
  const flowId = flowKeyOf(sessionId);

  // Reject if not PENDING (one-time transition)
  await ddb.send(
    new UpdateCommand({
      TableName: TABLE_NAME,
      Key: { userId, flowId },
      UpdateExpression: 'SET #st = :completed, boundAt = :now',
      ConditionExpression: '#st = :pending AND #ttl > :nowEpoch',
      ExpressionAttributeNames: { '#st': 'status', '#ttl': 'ttl' },
      ExpressionAttributeValues: {
        ':completed': 'COMPLETED',
        ':pending': 'PENDING',
        ':now': new Date().toISOString(),
        ':nowEpoch': Math.floor(Date.now() / 1000),
      },
    })
  );

  // Link the token to the Token Vault. On failure, roll back to PENDING with a conditional update
  await agentcore.send(
    new CompleteResourceTokenAuthCommand({
      sessionUri: sessionId,
      userIdentifier: { userToken: rawToken },
    })
  );
  return json(200, { status: 'bound' });
}

The frontend registers a PENDING record before displaying the authorization link, and completion requests without prior registration are rejected. State transitions are enforced by ConditionExpression to be one-time only: PENDING → COMPLETED, with attribute_not_exists added on the registration side to prevent overwriting completed flows. Rollback on failure is also conditional, reverting only the record that was set to COMPLETED back to PENDING. Abandoned flows automatically expire after 15 minutes via TTL.

The key is a composite key of userId + flowId, where flowId is the SHA-256 hash of the authorization URL's request_uri. Since the callback's session_id is the same URN as request_uri, only the record with a matching hash can be completed. A session_id from a different flow or a third party will have no matching record and will be rejected. Hashing is used to avoid storing the Bearer-equivalent value in plain text in the table.

The user's JWT is passed to CompleteResourceTokenAuthCommand's userIdentifier. The Token Vault links the token it temporarily stored under session_id to the user identified by that JWT.

Define Gateway and Targets in backend.ts

The Gateway itself is the MCP protocol entry point with Cognito as its JWT authorizer.

In this sample, SupportedVersions is set to 2025-11-25. At the time of writing, the MCP protocol versions supported by Gateway are 2026-07-28 / 2025-11-25 / 2025-06-18 / 2025-03-26 (a new version was just released recently!!), and the elicitation mechanism for returning authorization URLs to the client was introduced in the 2025-11-25 spec!

https://modelcontextprotocol.io/specification/2025-11-25/changelog

GitHub (MCP Server Target + Static Tool Schema)

We use the GitHub official remote MCP server as the target.

By default with MCP Server Targets, when the target is created, Gateway calls tools/list on the MCP server and caches the tool list as a catalog (Implicit Synchronization).

For 3LO targets, this call itself requires the user's access token, but at deployment time no one has authorized yet, so it can't be provided. As a result, the response to CreateGatewayTarget includes an authorization URL for the administrator, and the target stops at CREATE_PENDING_AUTH (awaiting authorization). During this time, updates, deletions, and re-synchronization are not accepted, and even after CloudFormation deployment completes, the target will not become READY. An operation where the administrator must approve from the console on every deployment makes it difficult to deploy in a single shot.

So this time I use the approach of passing tool definitions statically via mcpToolSchema. Since it doesn't perform tools/list to the upstream and simply caches the provided schema, no authorization is needed at creation time, and it becomes READY when deployment completes.

The official blog also recommends this approach for cases where human intervention during creation/update is not possible (with step-by-step instructions using GitHub MCP server as an example).

https://aws.amazon.com/blogs/machine-learning/connecting-mcp-servers-to-amazon-bedrock-agentcore-gateway-using-authorization-code-flow/

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-MCPservers.html

This isn't necessarily the definitive answer, but let's try this approach this time.

amplify/backend.ts (GitHub target)
new CfnResource(stack, 'GitHubMcpTarget', {
  type: 'AWS::BedrockAgentCore::GatewayTarget',
  properties: {
    Name: 'githubmcp',
    GatewayIdentifier: gateway.ref,
    TargetConfiguration: {
      Mcp: {
        McpServer: {
          Endpoint: 'https://api.githubcopilot.com/mcp/',
          McpToolSchema: {
            InlinePayload: mcpToolsSchema, // Contents of github-mcp-tools.json
          },
        },
      },
    },
    CredentialProviderConfigurations: [{
      CredentialProviderType: 'OAUTH',
      CredentialProvider: {
        OauthCredentialProvider: {
          ProviderArn: credentialProvider.getAtt('CredentialProviderArn').toString(),
          Scopes: ['repo', 'read:user'],
          GrantType: 'AUTHORIZATION_CODE',
          DefaultReturnUrl: callbackUrl,
        },
      },
    }],
  },
});

By specifying AUTHORIZATION_CODE for GrantType, it becomes 3LO (user delegation). The built-in GithubOauth2 vendor can be used as-is for the Credential Provider side.

The tool definitions are narrowed down to 6 read-only tools from those published by the GitHub MCP server (44 tools as of July 2026).

The static approach has a downside too: it doesn't automatically follow upstream tool definition changes. SynchronizeGatewayTargets cannot be called while using a static schema configuration, so the operation is to re-run the script and replace the schema when tools are updated. I accepted this maintenance cost for a single-shot deployment, but if there are many tools, the approach of manually resolving CREATE_PENDING_AUTH might be easier (in that case, the same authorization wait will occur on re-synchronization during changes).

One more thing: the tools/list response may include context-dependent descriptions such as the authenticated user's ID or whether semantic search is available. Hardcoding these as-is could lead to misidentification between users or exposure of personal information, so I decided to sanitize descriptions before using them this time.

Slack (MCP Server Target + CustomOauth2)

We connect the Slack official remote MCP server (mcp.slack.com/mcp) using the same static tool schema approach. The target definition is almost the same as GitHub so I'll omit it, and introduce the Slack-specific characteristics.

The Slack MCP server can only be called with a user token (xoxp-). However, the built-in SlackOauth2 vendor in AgentCore Identity uses the standard oauth.v2.access endpoint, so a bot token (xoxb-) gets stored in the Token Vault. This leads to a confusing failure where the authorization flow completes normally but only the tool calls are rejected with an Authorization error (vended logs that were useful for isolation are introduced in the appendix).

After isolating the cause, I arrived at using the CustomOauth2 vendor to explicitly specify Slack's user flow dedicated endpoint.

amplify/backend.ts (Slack Credential Provider)
const slackCredentialProvider = new CfnResource(stack, 'SlackCredentialProvider', {
  type: 'AWS::BedrockAgentCore::OAuth2CredentialProvider',
  properties: {
    Name: `slack-user-provider-${suffix}`,
    CredentialProviderVendor: 'CustomOauth2',
    Oauth2ProviderConfigInput: {
      CustomOauth2ProviderConfig: {
        ClientId: SLACK_CLIENT_ID,
        ClientSecretSource: 'EXTERNAL',
        ClientSecretConfig: {
          SecretId: SLACK_SECRET_NAME,
          JsonKey: 'client_secret',
        },
        OauthDiscovery: {
          AuthorizationServerMetadata: {
            Issuer: 'https://slack.com',
            AuthorizationEndpoint: 'https://slack.com/oauth/v2_user/authorize',
            TokenEndpoint: 'https://slack.com/api/oauth.v2.user.access',
            ResponseTypes: ['code'],
          },
        },
      },
    },
  },
});

oauth.v2.user.access is Slack's MCP-oriented endpoint that returns a user token in standard OAuth format. This allowed the correct user token to be stored in the Token Vault, enabling tool calls to work!

Google Calendar (OpenAPI Target)

Since Google Calendar doesn't have an officially available remote MCP server, we use Gateway's OpenAPI target. This is essentially the primary use case for Gateway — turning a REST API into MCP tools using an OpenAPI definition as-is. Even SaaS products that don't provide an MCP server can be connected using the same 3LO pattern as long as they have a REST API, and from the agent's perspective they can be called as tools like googlecal___listEvents.

For the OpenAPI definition, I hand-wrote only 3 read-only operations from the Calendar API (list calendars, list events, get event details).
There are tools for auto-generating from Discovery documents, but Gateway doesn't support schema references via $ref, and the response nesting was too deep to pass validation. Hand-writing is fine when there are few operations, but maintaining the definition would become tedious as endpoints increase to include write operations.

amplify/google-calendar-openapi.json(excerpt)
{
  "openapi": "3.0.3",
  "servers": [{ "url": "https://www.googleapis.com/calendar/v3" }],
  "paths": {
    "/calendars/{calendarId}/events": {
      "get": {
        "operationId": "listEvents",
        "description": "Retrieve a list of events for the specified calendar. Filter by period using timeMin/timeMax (RFC3339 format). Use calendarId='primary' for the user's own events.",
        ...
      }
    }
  }
}

The key points for the target definition are that TargetConfiguration becomes OpenApiSchema, and CustomParameters are passed in the OAuth configuration.

amplify/backend.ts(Google Calendar target)
new CfnResource(stack, 'GoogleCalendarTarget', {
  type: 'AWS::BedrockAgentCore::GatewayTarget',
  properties: {
    Name: 'googlecal',
    GatewayIdentifier: gateway.ref,
    TargetConfiguration: {
      Mcp: {
        OpenApiSchema: {
          InlinePayload: googleCalendarSchema, // OpenAPI definition content
        },
      },
    },
    CredentialProviderConfigurations: [{
      CredentialProviderType: 'OAUTH',
      CredentialProvider: {
        OauthCredentialProvider: {
          ProviderArn: googleCredentialProvider.getAtt('CredentialProviderArn').toString(),
          Scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
          GrantType: 'AUTHORIZATION_CODE',
          DefaultReturnUrl: callbackUrl,
          // Required for obtaining Google refresh tokens
          CustomParameters: {
            access_type: 'offline',
            prompt: 'consent',
          },
        },
      },
    }],
  },
});

Google access tokens expire after 1 hour. Token Vault automatically refreshes them using the refresh token, and the CustomParameters above are what requests its issuance.

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-authentication.html

On the Credential Provider side, the built-in GoogleOauth2 vendor could be used as-is!

Deployment and Callback URL Registration

Since the Client ID is configured to be passed via environment variables without being committed, you can deploy in sandbox with the following command.

Command
GITHUB_CLIENT_ID=xxx SLACK_CLIENT_ID=xxx GOOGLE_CLIENT_ID=xxx pnpm ampx sandbox

No code changes are needed for deployment to Amplify Hosting either. backend.ts determines the Hosting environment from build environment variables (AWS_BRANCH/AWS_APP_ID) and automatically switches the callback URL to https://<branch>.<appId>.amplifyapp.com/callback. Connect the repository to Hosting and set the same 3 Client IDs in the console environment variables to trigger the build (build configuration is in the repository's amplify.yml). The only thing you need to add in the console is a redirect rule that rewrites /callback to index.html for SPA routing. The operation verification in this article was done in this Hosting environment.

Once deployment is complete, callback URLs are output in amplify_outputs.json. For Hosting, you can download it from "Deployed backend resources" in the console.

amplify_outputs.json(excerpt)
{
  "custom": {
    "githubCallbackUrl": "https://...",
    "slackCallbackUrl": "https://...",
    "googleCallbackUrl": "https://..."
  }
}

Set each URL as the Authorization callback URL in GitHub's OAuth App, the Redirect URLs in Slack's OAuth & Permissions, and the Authorized redirect URIs in GCP's OAuth client.

Verification

Let's try it out! Access the Amplify URL, sign up and log in, then start chatting.

First, GitHub. When I send "Tell me about my repositories," an authorization link appears at the point of the tool call. Opening the link takes you to GitHub's authorization screen, and upon approval the callback tab closes automatically, the retry succeeds in the original chat, and the agent resumes processing.

GitHub authorization link and response

Slack follows the same flow. After selecting a workspace from the authorization link and approving, message search results were returned.

Slack authorization link and search results

Google Calendar is the same. Since it's in test mode, an unverified app warning appears, but you can proceed by continuing.

Google authorization screen

Upon approval, the Calendar API is called via the OpenAPI target and events are returned. In the integration settings panel on the right, all 3 services show "Connected."

Google Calendar event response and integration settings panel

Once authorized, tokens are stored in Token Vault, so subsequent responses are provided through integration with the MCP Server without an authorization link!

Security Considerations for Production Use

By delegating 3LO to Gateway, external service tokens are less exposed. The fact that acquisition, storage, and attachment are all handled between Gateway and Token Vault, and tokens no longer appear in agent code or the frontend, is a great benefit!
Also, since everything goes through Gateway, there are aggregation benefits such as using Policy for authorization control to select available tools and applying guardrails.

Session Binding extracted the verification of legitimate users — which was a challenge in the previous iteration — into Cognito JWT authentication and DynamoDB state management! Since the flow is uniquely matched by a flowId that hashes the request_uri, flows cannot be completed with a session_id that the user didn't initiate. Since the callback screen is implemented to auto-complete on access, in production it might be worth adding a confirmation button before completion.

All tools used this time were made read-only. Since system prompt instructions alone are insufficient as a safety boundary for writes, write tools (such as slack_send_message) are excluded along with their OAuth scopes (chat:write). I'm considering trying write tools such as Slack posting and calendar registration in the future, and when I do, I'd like to verify them with only the necessary ones, along with pre-execution user confirmation and guardrails.

There are also some loose settings due to the demo nature. (Bear with me...)
CORS for the Session Binding API is *, Gateway's Identity-related IAM is Resource: *, and ExceptionLevel remains DEBUG (a setting that returns detailed errors to the client). The mapping between conversation UUIDs and users is also unverified on the server side, so these are points I'd want to revisit if productionizing.

Conclusion

Including the Session Binding that I had compromised on locally last time, I was able to run the entire 3LO flow serverlessly and consolidate 3 external services into a single Gateway!

The ability to choose between MCP server target when there's an official MCP server, and OpenAPI target when there isn't, was interesting even when building it as a sample! My personal summer homework is done and I feel great!

There are some rough edges, but I hope it's at least a little helpful!
Please feel free to raise Issues for feedback or feature additions!

That concludes entry #6 of the "Summer Vacation Independent Research Relay": "Connecting GitHub, Slack, and Google Calendar to an AI Agent Using User-Delegated Authorization (3LO) with Amazon Bedrock AgentCore Gateway."

Next time will be an entry by なゆた on "Chronos-2 × Snowflake." Stay tuned!!

Supplementary Notes

Comparison with the @requires_access_token Approach

A comparison between the previous approach of handling tokens directly within the agent and the current approach of delegating to Gateway's outbound authentication.

Aspect @requires_access_token approach Gateway outbound authentication approach
Token visibility scope Passed to agent code Handled entirely between Gateway and Token Vault
External API calls Agent implements directly Gateway turns existing MCP servers or OpenAPI schemas into tools
Waiting for authorization completion SDK has built-in polling Retry via hook
Conversation memory Custom implementation AgentCore Memory + Session Manager
Expansion to multiple services Tool implementation per service Authorization handling is shared. Tool selection, connection check, and UI metadata are added

For small-scale configurations where a single agent is self-contained, the previous approach is simpler, but when you want to reuse tools from multiple agents or want to isolate tokens from application code, the Gateway approach is more suitable!

About Google's Official Workspace MCP Server

Google has actually started publishing an official remote MCP server (Developer Preview). Dedicated endpoints are available for Calendar, Gmail, Drive, Chat, Docs, Sheets, Slides, and People respectively (for example, Calendar uses calendarmcp.googleapis.com/mcp/v1). Authentication uses OAuth 2.0 web application type clients.

https://developers.google.com/workspace/guides/configure-mcp-servers

It looks like a configuration that can be connected as an MCP server target of AgentCore Gateway, but this was not verified this time. Depending on the organization's API controls and OAuth policies, administrator-side configuration may be required. If you can't connect, check administrator policies and OAuth logs.

Adding Another Service (Gmail Example)

Here are the steps for integrating a new service as well. The approach splits into two depending on whether an official MCP server exists.

If an official MCP server exists, use the same MCP server target approach as GitHub and Slack. For Gmail, Google's official Workspace MCP server (Developer Preview) has a dedicated endpoint. To try connecting, add an entry for the target server to SERVERS in scripts/fetch_mcp_tools.py to generate a static schema, then add the target.

The OAuth client and Credential Provider configuration is the same as Google Calendar.

If there's no official MCP server, use the same OpenAPI target approach as Google Calendar. Hand-write only the operations you want to use in the OpenAPI definition and add the target. If it's a REST API like the Gmail API, you can connect using this approach.

With either approach, there are 5 things to change:

  1. Create an OAuth app on the service side and register the Client Secret in Secrets Manager in JSON format
  2. Add the Credential Provider and target to backend.ts, and register the issued callback URL on the service side
  3. Add the prefix mapping to TOOL_PREFIX_TO_PROVIDER in gateway_auth.py, and add a read-only tool for connection verification to PROVIDER_PROBES in connections.py
  4. Add usage instructions for the new service's tools to the system prompt in main.py (extracting into skills is also an option)
  5. Add the new service to ProviderId in the frontend's types/runtime.ts and to the display information in connectionState.ts

The authorization hook itself is a provider-common mechanism requiring no changes — additions are limited to configuration and metadata only.

When adding services from the same account, like Google Calendar and Gmail, be careful about scope differences. If you add required scopes, existing tokens won't have sufficient permissions and re-authorization will be needed.

Also, while it's tempting to keep adding tools, since descriptions directly affect the model's tool selection accuracy, consider them based on your use cases.

Why I Chose Amplify

The reason I chose Amplify Gen 2 this time is that it lets me manage Cognito authentication, hosting, and a CDK backend in a single project. Particularly convenient is that Cognito User Pool and User Pool Client definitions can be done in just a few lines with Amplify Auth.

On the other hand, AgentCore resource definitions are fundamentally unrelated to Amplify, and they're riding along in backend.ts. It was necessary to be careful about where to place AgentCore resources to avoid circular references caused by Amplify's stack splitting rules (auth stack, function stack, etc. are separated).

In terms of development experience, the sandbox environment creates all resources including Gateway, Runtime, and CodeBuild, so the initial deployment takes more than 5 minutes, and subsequent deployments also take considerable time, which is a pain point.

For production use, it would be cleaner to manage infrastructure with CDK alone without mounting on Amplify, and serve the frontend with CloudFront + S3. For this demo, I prioritized the convenience of having everything from authentication to hosting set up in one shot!

About Development in Sandbox Environments

backend.ts can create all resources including Gateway and Runtime even in sandbox. Resource names have per-environment suffixes to avoid conflicts, and the callback destination points to the local development server, so the 3LO flow can be run from a local frontend. Since GitHub OAuth App only allows one callback URL to be registered, please use separate OAuth Apps for development if you want to run 3LO in both sandbox and Hosting.

Vended Logs Are Useful for Debugging

What was useful for isolating the Slack bot token issue was Gateway's log delivery (vended logs). Setting ExceptionLevel to DEBUG returns error details to the client, and enabling vended logs in the console records requests and responses between Gateway and targets in CloudWatch Logs. This allowed me to notice from the Slack MCP server's error response that a bot token was being sent. Since the space between Gateway and targets tends to be a black box, I feel it's best to enable this first when you get stuck!

Deleting Resources

Deleting the Amplify app will collectively delete Hosting, Cognito, and the Session Binding API, as well as the CDK-managed Gateway, targets, Credential Provider, Memory, and Runtime. The 3 Secrets Manager secrets and each service's OAuth apps need to be deleted manually.

References

I also referenced this greatly before — I was able to understand the mechanisms through this blog...!!
Thank you so much!

https://qiita.com/icoxfog417/items/4f90fb5a62e1bafb1bfb

Share this article

Related articles