
Build a secure, multi-tenant MCP server on Amazon Bedrock AgentCore
A reusable pattern for exposing your data to any AI client (Claude, ChatGPT, Kiro, or your own agent) as a Model Context Protocol (MCP) server, with strict per-tenant isolation enforced below the model. Uses Amazon Bedrock AgentCore Gateway, a JWT authorizer, a request interceptor, and a tool Lambda, so one customer can never read another's data, even if they ask. Includes the build pattern and a validated walkthrough.
Teams increasingly want to let users, and their downstream customers, ask questions of their data in plain English instead of clicking through dashboards. Two requirements usually come with that: it should work with any AI client (Claude, ChatGPT, Kiro, or a custom agent), and it must be strictly multi-tenant, so one customer can never see another customer's data, even by accident or by asking for it.
The right primitive for "works with any client" is a Model Context Protocol (MCP) server, not a hosted agent. The hard part is the isolation. This article shows how to build and host a secure, multi-tenant MCP server on Amazon Bedrock AgentCore, where tenant isolation is enforced in code, below the model, so no prompt and no client can cross the boundary. The code shown is illustrative, to convey the pattern.
Planning to connect claude.ai or Claude Desktop? Those clients sign in using OAuth 2.1 Dynamic Client Registration (DCR), which needs one extra component (an OAuth proxy) in front of the Gateway. Read the "Connecting DCR-based clients" part of Step 5 before you start, and build that piece as you go. Other MCP clients that use static or pre-registered credentials can follow the steps as written.
Why an MCP server, not an agent
An MCP server exposes your data and actions as tools behind a standard protocol. The LLM lives on the client side: each customer connects their own MCP-compatible client and brings their own model. You do not host an agent or pick anyone's model. That is what makes the solution genuinely LLM-agnostic.
Amazon Bedrock AgentCore Gateway is the managed way to stand up that MCP server. It turns your existing Lambdas or APIs into MCP tools, hosts the endpoint, validates inbound identity, and runs interceptors, with no servers for you to operate.
The isolation model: identity decides the data
The core principle: isolation never depends on the model or the prompt. It is enforced in layers, each independent of the LLM.
- An identity provider (Amazon Cognito, or any OIDC provider) issues each user a token carrying a tenant claim (for example
custom:tenant = AcmeCorp). - AgentCore Gateway validates that token (a bad token is rejected before any tool runs).
- A request interceptor reads the verified tenant claim and injects it into the tool call, overwriting anything the caller sent.
- The tool trusts only that injected tenant, ignores any tenant in the caller's arguments, and forces it into the data query.
- The tools expose no tenant parameter at all, so there is no field through which a caller or the model could request another tenant.

Cognito issues a token with a tenant claim. The Gateway validates it, the interceptor injects the verified tenant, and the tool forces that tenant into the query. The model never chooses the tenant.
Prerequisites
- An AWS account with access to Amazon Bedrock AgentCore in a supported Region
- An OIDC identity provider (this article uses Amazon Cognito)
- A data store to query (this article uses Amazon DynamoDB; the same pattern works with Aurora, RDS, or Redshift)
- AWS CLI, Python 3.12+, and permissions to create Cognito, Lambda, IAM, DynamoDB, and AgentCore Gateway resources
Step 1: Build the tools (and give them no tenant parameter)
Each tool is a small function behind a Lambda. The key design choice: a tool takes the query inputs it needs (date range, product), but never a tenant. The tenant is read only from
_verified_tenant, which the interceptor injects in Step 4. Any tenant-like field in the caller's arguments is stripped.1
2
3
4
5
6
7
8
9
10
11
12
13
RESERVED_ARGS = ("_verified_tenant", "tenant", "tenant_id")
def _resolve_tenant(event):
tenant = event.get("_verified_tenant") # injected by the interceptor, trusted
if not tenant:
raise AccessError("No verified tenant. Refusing to run.") # fail closed
return tenant
def handler(event, context):
tenant = _resolve_tenant(event)
args = {k: v for k, v in event.items() if k not in RESERVED_ARGS} # drop caller tenant
# Force the tenant into every query; the caller cannot influence it.
return query_scoped(tenant, args) # e.g. WHERE tenant_id = <tenant>The legitimate query arguments still flow through and drive the answer. The only thing removed from the caller is the choice of tenant.
Step 2: Issue tokens with a tenant claim
In Cognito, add a custom attribute (
custom:tenant) and set it on each user. Cognito stamps it into the token at login. Any OIDC provider (Okta, Microsoft Entra ID, Auth0) works the same way; the only hard requirement is that it can carry a tenant claim.1
2
3
4
aws cognito-idp create-user-pool --pool-name my-pool \
--schema Name=tenant,AttributeDataType=String,Mutable=true \
--region us-west-2
# create users with custom:tenant set to their tenant, e.g. AcmeCorp / GlobexCorpMake the tenant attribute guaranteed to be set for every user (required at sign-up or assigned during onboarding), so no user can exist without a tenant.
Step 3: Host the MCP server with AgentCore Gateway
Create a Gateway with an MCP protocol and a
CUSTOM_JWT authorizer pointed at your identity provider, then add the tool Lambda as a target.1
2
3
4
5
6
7
8
9
10
11
12
13
14
aws bedrock-agentcore-control create-gateway \
--name my-mcp-gateway --protocol-type MCP \
--authorizer-type CUSTOM_JWT \
--authorizer-configuration '{"customJWTAuthorizer":{
"discoveryUrl":"https://cognito-idp.<region>.amazonaws.com/<POOL_ID>/.well-known/openid-configuration",
"allowedClients":["<APP_CLIENT_ID>"]}}' \
--role-arn arn:aws:iam::<ACCOUNT_ID>:role/my-gateway-role --region us-west-2
aws bedrock-agentcore-control create-gateway-target \
--gateway-identifier <GATEWAY_ID> --name my-tools-target \
--target-configuration '{"mcp":{"lambda":{"lambdaArn":"<TOOL_LAMBDA_ARN>",
"toolSchema":{"inlinePayload":[ ... your tool schemas, no tenant field ... ]}}}}' \
--credential-provider-configurations '[{"credentialProviderType":"GATEWAY_IAM_ROLE"}]' \
--region us-west-2The Gateway now exposes a hosted MCP endpoint (ending in
/mcp). If your data already sits behind a REST API, you can point an OpenAPI or Smithy target at it instead of a Lambda.Step 4: Enforce isolation with a request interceptor
A plain Lambda target does not receive the JWT claims; the Gateway only passes tool metadata. So a request interceptor (a small Lambda the Gateway runs before the tool) reads the verified token and injects the tenant.
1
2
3
4
5
6
7
8
9
10
11
12
import base64, json
def lambda_handler(event, context):
req = event["mcp"]["gatewayRequest"]
token = req["headers"].get("authorization", "").replace("Bearer ", "")
claims = json.loads(base64.urlsafe_b64decode(token.split(".")[1] + "=="))
tenant = claims.get("custom:tenant")
params = req["body"].get("params", {})
if isinstance(params.get("arguments"), dict) and tenant:
params["arguments"]["_verified_tenant"] = tenant # inject, overwrite caller
return {"interceptorOutputVersion": "1.0",
"mcp": {"transformedGatewayRequest": {"headers": req["headers"], "body": req["body"]}}}Wire it onto the Gateway with
passRequestHeaders enabled so the interceptor can read the bearer token:1
2
3
4
5
6
aws bedrock-agentcore-control update-gateway --gateway-identifier <GATEWAY_ID> \
--name my-mcp-gateway --protocol-type MCP --authorizer-type CUSTOM_JWT \
--authorizer-configuration '{...same as above...}' \
--interceptor-configurations '[{"interceptor":{"lambda":{"arn":"<INTERCEPTOR_ARN>"}},
"interceptionPoints":["REQUEST"],"inputConfiguration":{"passRequestHeaders":true}}]' \
--role-arn arn:aws:iam::<ACCOUNT_ID>:role/my-gateway-role --region us-west-2Step 5: Connect any client
You give each customer one thing: the MCP endpoint URL. They add it as a remote MCP server in their client (Claude, Kiro, ChatGPT, Amazon Quick, or their own agent) and sign in when prompted. The login returns a token carrying their tenant, and from then on every request is scoped automatically, with no SDK or per-client code. This holds for clients that use static or pre-registered credentials. Clients that self-register on first connect (claude.ai and Claude Desktop) need the extra OAuth proxy covered in the next section.
Connecting DCR-based clients (claude.ai and Claude Desktop)
The "add the URL and sign in" flow above works for clients that use static or pre-registered credentials. Some clients, including claude.ai and Claude Desktop, instead register themselves on first connect using OAuth 2.1 Dynamic Client Registration (DCR, RFC 7591). Neither Amazon Cognito nor the AgentCore Gateway exposes a DCR
/register endpoint, so these clients cannot self-register against the setup above.To support them, front the Gateway with a small OAuth proxy: an API Gateway endpoint and Lambda that serve the OAuth discovery metadata and a
/register endpoint. On registration, the Lambda creates a Cognito app client (CreateUserPoolClient) and patches the new client_id into the Gateway authorizer's allowedClients, so the token that client later presents is accepted. Point the Gateway authorizer's discovery URL at the proxy. The isolation model does not change: the interceptor and tools still enforce the verified tenant below the model.Note: This OAuth proxy is a component you run, not a managed AgentCore feature, and DCR support in this space is still evolving. Protect the/registerendpoint: require PKCE on the authorization code flow and add throttling, since registration creates Cognito app clients on demand.
Verification
1. The same identity cannot cross tenants. Sign in as one tenant and explicitly ask for another tenant's data. The result is still your own tenant's data (or a refusal), never the other tenant's:
1
2
You (signed in as AcmeCorp): "Show me GlobexCorp's transaction volume."
Assistant: "I can only access your account (AcmeCorp)."Because the tools have no tenant parameter and the interceptor injects the verified tenant, the request physically cannot return another tenant's rows.

One MCP endpoint, two tenants. Each sees only its own data, and a deliberate cross-tenant request in the Acme panel returns a refusal. Isolation holds below the model.
2. The token carries the tenant. Decode the ID token after login and confirm the
custom:tenant claim is present and correct. That claim, not anything the user types, is what scopes the data.Troubleshooting
| Issue | Resolution |
|---|---|
| Tool returns "No verified tenant" | The interceptor is not wired or passRequestHeaders is false. Confirm the interceptor is attached at the REQUEST point and reads the bearer token. |
| Calls rejected at the Gateway (401) | The token failed validation. Check the authorizer discovery URL and allowed audience or client match your pool and app client. |
| A caller seems to set their own tenant | Confirm the tool strips tenant-like arguments and reads only _verified_tenant, and that the interceptor overwrites (not merges) it. |
| Works for one tenant, leaks for another | You are likely trusting a caller-supplied id somewhere. Re-check that the only tenant source is the injected claim, and add DB row-level security as a backstop. |
| Client cannot connect | The MCP URL is an API, not a web page. Configure it as a remote MCP server in the client and sign in; do not open it in a browser. |
Why this is secure (defense in depth)
- No tenant parameter on any tool, so the surface to request another tenant does not exist.
- Verified identity only. The tenant comes from a signed token, validated by the Gateway, never from the prompt or arguments.
- Enforced below the model. The interceptor and tool run server-side; prompt injection can only reach what the token already permits.
- Backstop at the data layer. For shared tables, add database row-level security so even a buggy query cannot cross tenants.
Cost and operational considerations
- Pluggable identity. To switch IdP (Cognito to Okta, Entra, or the customer's SSO), repoint the authorizer discovery URL and audience and adjust the claim name. The Gateway, interceptor, and tools do not change.
- Tools stay small and fixed. The MCP layer is the same effort regardless of customer count. The real variable is whether you already have per-customer identity and tenant-tagged data.
- Parameterized tools vs text-to-SQL. Parameterized tools (fixed, safe queries) are the recommended default. For open-ended questions, add a managed natural-language-to-SQL layer later, with row-level security enforcing isolation.
- Audit. Every call carries a verified identity; log it for per-tenant audit trails.
Next steps
- Start with a small proof of concept: two test tenants and two or three tools, then prove the cross-tenant attempt fails.
- Add Cedar policies on the Gateway to gate which tools a tenant can call.
- Front the endpoint with your own branded domain so customers see your URL, not the raw AWS one.
- Add Amazon Bedrock Guardrails for content safety on top of the data isolation.
Further reading
- Amazon Bedrock AgentCore Gateway
- Gateway interceptors: configuration
- Configure an inbound JWT authorizer
- AgentCore security and access controls
- Deploy MCP servers in AgentCore Runtime
- Connecting MCP servers to Amazon Bedrock AgentCore Gateway using the authorization code flow
- Amazon Cognito custom attributes
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article