Enterprise MCP Governance Gateway (AWS AgentCore)
A real, deployed governance layer that sits in front of MCP tool servers using Amazon Bedrock AgentCore Gateway. It gives an enterprise a single, governed MCP endpoint that:
- Authenticates every caller with a JWT (Amazon Cognito OIDC, inbound auth).
- Authorizes each tool call with Cedar policies evaluated by an AgentCore policy engine (role-, scope-, and user-based allow/deny).
- Inspects and transforms requests and responses with Lambda interceptors
(SQL-injection blocking, business-hours gating, PII redaction, payload
truncation, structured audit logging) — backed by a managed Amazon Bedrock
Guardrail (
ApplyGuardrail: prompt-attack/content filters on the request, PII anonymization on the response), with the local regex rules as defense-in-depth. - Fronts MCP tool servers (here: two sample Lambda targets,
DocsAPIandDatabaseAPI) behind one MCP URL that an MCP client (e.g. Kiro) connects to.
Provisioned with AWS CDK (Python) to your account in us-west-2
(configurable). After deploy, the live endpoint and all non-secret resource ids
are published to AWS Systems Manager Parameter Store under
/enterprise-mcp-gateway/* (and as CloudFormation stack outputs); the demo
credential lives in AWS Secrets Manager.
Migration note: this stack was originally provisioned by a boto3 script (
deploy.py). It is now an AWS CDK app undercdk/(opens in new tab).
Quickstart
Deploy, then prove the governance works. Five steps, ~5 minutes. Prerequisites:
Node.js + npm install -g aws-cdk@2.1129.0, Python 3.12+, and AWS credentials for
the target account.
export AWS_REGION=us-west-2
python3 -m venv .venv && source .venv/bin/activate
pip install -r cdk/requirements.txt -r requirements-dev.txt
# 1. deploy the gateway (one stack; ~2 min)
cd cdk && cdk bootstrap && cdk deploy EnterpriseMcpGatewayStack --require-approval never && cd ..
# 2. create the demo users (CloudFormation can't set a Cognito password)
bash scripts/seed-demo-users.sh
# 3. mint a JWT as admin@example.com
source scripts/get-token.sh
# 4. prove it: 5 governance tests against the LIVE gateway, never mocked
GATEWAY_URL="$(aws ssm get-parameter --region "$AWS_REGION" \
--name /enterprise-mcp-gateway/gateway/url --query Parameter.Value --output text)" \
AUTH_TOKEN="$AGENTCORE_JWT" python3 -m pytest tests/integration -q
# 5. drive it from a real agent
bash scripts/connect-coding-agent.sh kiro # or: claude-register
Step 4 should report 5 passed (3 Atlassian tests skip unless you also deploy the connector). Then open this folder in Kiro, reload MCP servers, and work through the governance walkthrough — the part that actually demonstrates allow, deny, block and redact.
When you're done, disconnect the client and tear down.
Everything below is reference: how it works, what each control does, and the optional Atlassian connector.
Verified architecture
MCP client (Kiro)
│ HTTPS + Bearer JWT (Cognito access token)
▼
┌──────────────────────────────────────────────────────────┐
│ AgentCore Gateway (authorizerType = CUSTOM_JWT) │
│ │
│ 1. JWT validated against Cognito OIDC discovery URL │
│ 2. REQUEST interceptor Lambda (audit, SQL/abuse block) │
│ 3. Cedar policy engine (ENFORCE) — allow/deny per tool │
│ 4. Target Lambda invoked (GATEWAY_IAM_ROLE credential) │
│ 5. RESPONSE interceptor Lambda (PII redact, truncate) │
└──────────────────────────────────────────────────────────┘
│ │
▼ ▼
DocsAPI Lambda DatabaseAPI Lambda
(get_page, search, (execute_query, drop_table,
create/update/...) export_pii_report, ...)
Everything is provisioned with AWS CDK (Python) using the
AWS::BedrockAgentCore::* L1 constructs (CfnGateway, CfnGatewayTarget,
CfnPolicyEngine, CfnPolicy) plus standard IAM / Lambda / Cognito / Secrets
Manager / SSM constructs.
Key verified mechanisms:
- Inbound auth —
CfnGateway(authorizerType="CUSTOM_JWT", authorizerConfiguration={customJWTAuthorizer: {discoveryUrl, allowedClients}}). The Cognito access token carriessub,username, andscope, which the policy engine exposes as Cedar principal tags. - Authorization (Cedar) — a
CfnPolicyEngineplus oneCfnPolicyper Cedar statement (definition.cedar.statement), attached to the gateway viapolicyEngineConfiguration={arn, mode: "ENFORCE"}. - Interceptors —
interceptorConfigurations(a list of REQUEST + RESPONSE Lambda configs) onCfnGateway. - Targets —
CfnGatewayTargetLambda targets withtargetConfiguration.mcp.lambda.{lambdaArn, toolSchema.inlinePayload}andcredentialProviderConfigurations=[{credentialProviderType: GATEWAY_IAM_ROLE}]. Tools surface as<TargetName>___<tool>(triple underscore), e.g.DocsAPI___get_page. - MCP URL — the real
gatewayUrl(CfnGatewayattribute) is published to SSM and as a CloudFormation output. It is not hand-constructed.
Cedar specifics
- Principal type is
AgentCore::OAuthUser; JWT claims become tags (principal.getTag("scope"),principal.getTag("username")). - Action ids are
AgentCore::Action::"<Target>___<tool>"; resource isAgentCore::Gateway::"<gatewayArn>"(the CDK injects the real ARN token). - The engine derives its Cedar schema from the registered tools, so the CDK
declares each
CfnPolicywithadd_dependency()on the targets — targets are created (schema populated) before any policy that references a tool. One single-action statement per tool —action == AgentCore::Action::"...". - Numeric tool arguments map to the Cedar
decimalextension; compare with.greaterThan(decimal("1000.0")), not> 1000.
Repository layout
cdk/ # AWS CDK app (Python)
app.py # App entry point (instantiates the stack)
cdk.json # CDK CLI config
requirements.txt # aws-cdk-lib (pinned) + constructs
enterprise_gateway/
gateway_stack.py # the whole stack (IAM, Lambdas, Cognito, gateway, policies, SSM)
connectors/ # governed connector stacks + custom resources (CDK)
atlassian_stack.py # Atlassian: Runtime + OAuth2 provider (EXTERNAL secret) + target + 12 Cedar policies
connector_auth_stack.py # shared per-user 3LO consent SPA (Cognito Hosted UI + Identity Pool + S3/CloudFront)
scripts/seed-demo-users.sh # post-deploy: create the 3 demo users + set password from Secrets Manager
scripts/register-user.sh # self-service: register YOUR OWN email as a user (real testing)
scripts/get-token.sh # mint a Cognito JWT -> $AGENTCORE_JWT / $AUTH_TOKEN (SSM + Secrets Manager)
scripts/test-gateway.sh # curl smoke test of the live gateway (URL from SSM)
scripts/connect-coding-agent.sh # connect Claude Code / Kiro to the gateway
scripts/deploy-connector-auth.sh # publish the consent SPA (config.json from stack outputs + S3 sync + CloudFront)
scripts/mcp_client.py # minimal Python MCP client helper
policies/*.cedar # Cedar source (active + disabled-for-now sets)
policies/manifest.json # which .cedar files the stack loads, in order
schemas/*.json # inlinePayload tool schemas for the two targets
lambdas/ # request/response interceptors + sample targets
connectors/atlassian/ # Atlassian MCP server (Runtime source) + authorize.py (dev 3LO shortcut)
connectors/authorize-spa/ # consent SPA static files (index.html, callback.html, config.example.json)
kiro-config/mcp.json # Kiro MCP config template (__GATEWAY_URL__ token)
tests/unit/ # interceptor unit tests (no AWS)
tests/integration/ # live gateway governance tests
requirements-dev.txt # test/tooling deps (pytest, requests, boto3) — see "Run tests"
LICENSE # MIT-0
There is no DEPLOY_STATE.json — non-secret resource ids/URLs live in SSM
Parameter Store (/enterprise-mcp-gateway/*) and CloudFormation outputs; the
demo credential lives in Secrets Manager.
Deploy
Prerequisites:
- Node.js + AWS CDK Toolkit (the
cdkCLI), pinned to the version this sample was validated with:npm install -g aws-cdk@2.1129.0. - Python 3.12+ and the CDK Python deps. A virtualenv is recommended:
python3 -m venv .venv && source .venv/bin/activate pip install -r cdk/requirements.txt - AWS credentials for the target account with permission to create the resources.
Region defaults to
us-west-2(override withAWS_REGION/CDK_DEFAULT_REGION). - A container runtime (Docker, Finch, or Podman) running — the connector and
connector stacks bundle their Lambda dependencies with CDK's Docker bundling (it runs
pip installinside the official Lambda build image), and the Atlassian connector also builds a container image. With Finch, prefix CDK commands withCDK_DOCKER=finch, e.g.CDK_DOCKER=finch cdk deploy. The core gateway stack alone does not need it.
export AWS_REGION=us-west-2
aws sts get-caller-identity # confirm the target account
cd cdk
cdk bootstrap aws://$(aws sts get-caller-identity --query Account --output text)/$AWS_REGION # first time per acct/region
# This CDK app holds three stacks (the gateway + the two optional connector stacks), so
# name the one you want — a bare `cdk deploy` will stop and ask which. `cdk ls` lists them.
cdk deploy EnterpriseMcpGatewayStack # review the IAM change summary, then approve
The connector stacks (
AtlassianConnectorStack,ConnectorAuthStack) are optional and deployed later — they read this stack's ids from SSM, so the gateway must exist first. See Governed connectors.
The stack:
- Creates the gateway exec role (least-privilege; per-Lambda exec roles are CDK-managed) and the 4 Lambdas (request/response interceptors + 2 targets).
- Creates the Cognito user pool, resource server, and an admin-auth app client, plus a Secrets Manager secret holding a generated demo password.
- Creates the Cedar policy engine, then the gateway (CUSTOM_JWT +
interceptors + policy engine in
ENFORCE), with a SourceArn-pinned Lambda invoke grant (confused-deputy protection). - Registers the
DocsAPIandDatabaseAPItargets, then the Cedar policies (each depending on the targets so the schema is populated first). - Publishes non-secret discovery values to SSM Parameter Store and as CloudFormation outputs.
Verify the deploy
One read-only check before going further (no token needed). It matters because a policy
engine that isn't in ENFORCE permits every call silently — you'd otherwise discover
that mid-demo:
cd ..
GW="$(aws ssm get-parameter --region "$AWS_REGION" \
--name /enterprise-mcp-gateway/gateway/id --query Parameter.Value --output text)"
aws bedrock-agentcore-control get-gateway --region "$AWS_REGION" --gateway-identifier "$GW" \
--query '{status:status,auth:authorizerType,interceptors:(interceptorConfigurations!=null),policyMode:policyEngineConfiguration.mode}'
Expect READY / CUSTOM_JWT / interceptors: true / policyMode: ENFORCE.
Seed the demo users
CloudFormation cannot set a Cognito permanent password, so a post-deploy script does it:
bash scripts/seed-demo-users.sh # creates demo users, sets password from Secrets Manager
Get a token
The gateway requires a Cognito JWT (inbound auth). Mint one from a demo user
(get-token.sh reads the pool/client from SSM and the password from Secrets
Manager, and authenticates with the IAM-gated ADMIN_USER_PASSWORD_AUTH flow —
no third-party SRP library, no public password flow):
source scripts/get-token.sh # exports $AGENTCORE_JWT and $AUTH_TOKEN
You need this for the curl smoke test, the integration tests, and hand-written MCP
configs. You do not need it before connect-coding-agent.sh — that script calls
get-token.sh itself and embeds a fresh token.
Optional — use a different demo user. This changes the governance outcome, because
the JWT's claims become Cedar principal tags, so the same tool call can be allowed for one
user and denied for another. Skip it to stay as admin@example.com:
export COGNITO_USERNAME=security-admin@example.com # default is admin@example.com
source scripts/get-token.sh
zsh note: the script is safe to
sourcein both bash and zsh. Set env overrides as their own line (export COGNITO_USERNAME=…) rather than inline (VAR=… source …), which zsh doesn't apply to sourced scripts.
Tokens expire (Cognito default 1 hour); re-source when they do.
Optional: test as yourself (register your own email)
Not required for any step below — the demo users are enough. But for realistic testing you can register your own email and mint a token as yourself:
bash scripts/register-user.sh you@example.com admin # role: admin | analyst | data-engineer
export COGNITO_USERNAME=you@example.com
source scripts/get-token.sh
register-user.sh creates the user in the pool with a permanent password (the same
generated credential from Secrets Manager) and sets a custom:role attribute. It is
idempotent — re-run to change your role or reset the password. If
COGNITO_USERNAME=you@example.com fails with UserNotFoundException, this script is the
step that was missed: the user has to exist in the pool first.
Note on
custom:role: setting it does not make role-based Cedar policies fire in this sample.custom:rolelands only in the ID token, which the gateway rejects — see the Query F limitation below.
This registers a local Cognito user for testing. In production you would federate the pool to your enterprise IdP instead of registering users directly, and every employee would authenticate as themselves through that IdP.
Hands-on governance walkthrough (the real test)
This is the test that matters: drive the governed tools through a real coding agent and watch the gateway allow, deny, block, and redact — server-side, no mocks. Everything below hits the live gateway with your JWT.
1. Connect a client
Prerequisite: you must have the Claude Code CLI or the Kiro CLI installed
and on your PATH — the script invokes the agent through your shell. The gateway must be
deployed and the demo users seeded; you do not need to mint a token first —
connect-coding-agent.sh calls get-token.sh itself and embeds a fresh JWT in the config
(so the ~1h clock starts when you connect, not earlier).
Claude Code — register the gateway as a project MCP server (mints a fresh JWT
and writes .mcp.json):
bash scripts/connect-coding-agent.sh claude-register
# then run `claude`, approve the server; tools appear as mcp__enterprise-gateway__*
Kiro IDE — write the workspace MCP config with a live URL + JWT:
bash scripts/connect-coding-agent.sh kiro # writes .kiro/settings/mcp.json
# open this workspace in Kiro; the enterprise-gateway tools appear in the MCP panel
Or fully driven/headless (the script asks the agent to call each tool and prints the
verbatim gateway response): bash scripts/connect-coding-agent.sh claude (or
kiro-cli).
The script authenticates as admin@example.com (the get-token.sh default) unless you
export COGNITO_USERNAME=… first. Keep the default — the expected outcomes below are written
for that user, and a different identity legitimately produces different results.
Configuring a client by hand (reference)
The script writes this for you; this is the shape it produces, at .kiro/settings/mcp.json
(Kiro) or .mcp.json (Claude Code). Get the URL from
aws ssm get-parameter --name /enterprise-mcp-gateway/gateway/url --query Parameter.Value --output text and the token from source scripts/get-token.sh:
{
"mcpServers": {
"enterprise-gateway": {
"type": "http",
"url": "https://<gateway-id>.gateway.bedrock-agentcore.us-west-2.amazonaws.com/mcp",
"headers": { "Authorization": "Bearer ${AGENTCORE_JWT}" }
}
}
}
Kiro requires "type": "http" — without it Kiro treats the entry as a stdio server and
silently ignores it. Kiro also reads two scopes: the workspace file above (only when this
folder is open) and the global ~/.kiro/settings/mcp.json (always). Use
connect-coding-agent.sh kiro-global to merge into the global one.
2. Ask these queries — and what governance should do
Ask the agent (or the headless script asks for you). The outcome is produced by the gateway, not the agent — that's the whole point.
| # | Ask the agent to… | Expected governance outcome | Enforced by |
|---|---|---|---|
| A | "List every tool from enterprise-gateway" | Only the Cedar-permitted tools appear (e.g. DocsAPI___get_page, search_pages, list_spaces, execute_query, export_pii_report). Forbidden tools like drop_table are filtered out of the list entirely. | Cedar (visibility) |
| B | "Call DocsAPI___get_page with pageId arch-overview" | Succeeds — returns the page content. | Cedar allow-docs-read (ALLOW) |
| C | "Call DatabaseAPI___drop_table with tableName users" | The agent cannot even attempt it — Cedar filtered it out at step A, so a well-behaved agent replies that no such tool exists. To see the DENY itself, call it directly (see below): Tool Execution Denied … [forbid_destructive_db_*]. | Cedar forbid-destructive-db (DENY) |
| D | "Call DatabaseAPI___execute_query with query DROP TABLE users; --" | Blocked before execution: Request blocked: dangerous SQL pattern detected … | REQUEST interceptor Lambda |
| E | "Call DatabaseAPI___export_pii_report for department engineering" | PII comes back masked: Name: {NAME}, SSN: {US_SOCIAL_SECURITY_NUMBER}, Credit Card: {CREDIT_DEBIT_CARD_NUMBER}, … — the {TYPE} form means the managed Bedrock Guardrail anonymized it. (If the guardrail is disabled the local regex backstop produces [REDACTED_SSN] instead.) In business hours; see note. | RESPONSE interceptor + Guardrail |
| F | "Call DocsAPI___create_page …" (a write) | Denied for demo users — No policy applies to the request (denied by default). create_page is role=admin-gated and custom:role never reaches the access token the gateway validates (documented limitation). | Cedar (default deny) |
Query F is a known demo limitation, not a bug. Cognito issues two tokens: an access token (carries
scope— this is the one the gateway validates) and an ID token (carriescustom:role, and the gateway rejects it with-32002 insufficient_scopebecause it has noscopeclaim).custom:roletherefore never reaches the policy engine, so the role-gatedpermitnever fires. A production setup adds a Cognito pre-token-generation Lambda to copycustom:roleinto the access token (see Tracked production hardening). To see the split for yourself, mint an ID token withTOKEN_TYPE=id source scripts/get-token.shand decode it —custom:roleis present there but absent from the access token. (unset TOKEN_TYPEafterwards; an ID token cannot be used against the gateway.)
The agent itself never decides any of this. It simply calls the tool; the gateway runs the JWT check → REQUEST interceptor → Cedar engine → target → RESPONSE interceptor, and returns the allow/deny/redacted result the agent reports back to you verbatim.
See the Cedar deny directly
Cedar both hides a forbidden tool and denies it, and hiding wins — so an agent never triggers the deny (case C). To see the engine refuse, bypass the agent:
source scripts/get-token.sh
GW="$(aws ssm get-parameter --region "$AWS_REGION" \
--name /enterprise-mcp-gateway/gateway/url --query Parameter.Value --output text)"
curl -s -X POST "$GW" -H "Authorization: Bearer $AGENTCORE_JWT" \
-H "Content-Type: application/json" -H "Mcp-Protocol-Version: 2025-11-25" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"DatabaseAPI___drop_table","arguments":{"tableName":"users"}}}'
# -32002 Tool Execution Denied ... [denied due to forbid_destructive_db_1-...]
The named policy proves the refusal was server-side, not the agent's judgement; a tool that
truly doesn't exist returns -32602 Unknown tool.
Business-hours note (case E):
export_pii_reportandquery_audit_logsare gated to 09:00–17:00 UTC by the REQUEST interceptor. Outside that window they are blocked before execution. To see PII redaction specifically outside hours, ask the agent to callDatabaseAPI___execute_querywithSELECT * FROM users— its sample rows contain an email/phone the RESPONSE interceptor redacts.
3. See the audit trail
Every call is logged by the interceptors. Tail the REQUEST interceptor log:
REQ_ARN="$(aws ssm get-parameter --region "$AWS_REGION" \
--name /enterprise-mcp-gateway/lambda/request-interceptor-arn --query Parameter.Value --output text)"
aws logs tail "/aws/lambda/${REQ_ARN##*:}" --region "$AWS_REGION" --since 15m --format short
You'll see one structured request_audit record per call (user, tool, args keys)
and request_blocked records for the SQLi / business-hours blocks. Two things worth knowing:
- Refused calls are audited too — the interceptor runs before Cedar, so a denied
drop_tablestill leaves a record of the attempt. useris the Cognitosub, not the email (the access token carriessub), and argument values are never logged — only names, plus the matched rule and value length on a block.
Run tests
Install the test dependencies (pytest, requests, …) — separate from the deploy deps in
cdk/requirements.txt:
source .venv/bin/activate
pip install -r requirements-dev.txt
Integration tests — these are the ones that validate your deployment. They run against the live gateway and are never mocked, so they prove the deployed system really enforces governance:
source scripts/get-token.sh
GATEWAY_URL="$(aws ssm get-parameter --region "$AWS_REGION" \
--name /enterprise-mcp-gateway/gateway/url --query Parameter.Value --output text)" \
AUTH_TOKEN="$AGENTCORE_JWT" \
python3 -m pytest tests/integration -v
If GATEWAY_URL is unset the integration module is skipped automatically.
Curl smoke test — the same idea in one command (resolves the URL from SSM):
source scripts/get-token.sh
bash scripts/test-gateway.sh
Unit tests exercise the interceptor and Cedar-parsing logic against fakes — no AWS, no token. They pass whether or not anything is deployed, so run them when you change the code (or in CI), not to check a deploy:
python3 -m pytest tests/unit -v
What the tests prove
C1–C5 run against the deployed, live gateway — never mocked: tools/list is
Cedar-filtered (C1), an allowed call succeeds (C2), a forbidden tool is denied (C3), a
SQL-injection payload is blocked by the REQUEST interceptor (C4), and PII in a response is
redacted (C5). C6 is the local interceptor unit tests — the only no-AWS row.
Inspecting the live policy
Policies are live CfnPolicy resources in the deployed engine — you don't edit .cedar
files to change what's enforced. To see exactly what the engine holds right now:
PE="$(aws ssm get-parameter --region "$AWS_REGION" \
--name /enterprise-mcp-gateway/policy-engine/id --query Parameter.Value --output text)"
aws bedrock-agentcore-control list-policies --region "$AWS_REGION" \
--policy-engine-id "$PE" --max-results 50 \
--query 'policies[].{name:name,status:status}' --output table
Remember that the identity you test as changes the result — the JWT's claims become Cedar principal tags, so a token for a different user can flip an ALLOW to a DENY.
Governed connectors (per-user SaaS)
Beyond the sample Lambda targets, the gateway can front per-user SaaS connectors —
each caller authorizes AWS to act on their own SaaS account (OAuth), while the same
Cedar + interceptor governance applies. A shared, provider-generic consent SPA
(ConnectorAuthStack, Cognito Hosted UI + Identity Pool + S3/CloudFront) completes the
browser-based consent for MCP clients (e.g. Kiro) that can't drive it themselves.
Available connectors — see each connector's README for prerequisites, deploy, consent, and test steps:
- Atlassian (Jira & Confluence) — per-user OAuth
3LO; read = all, write = role-gated (
atlassian-writer).
Discovery & state (SSM + Secrets Manager)
After deploy, fetch every non-secret value in one call:
aws ssm get-parameters-by-path --region "$AWS_REGION" \
--path /enterprise-mcp-gateway --recursive \
--query "Parameters[].[Name,Value]" --output table
Published parameters include gateway/{url,id,arn}, policy-engine/{id,arn},
cognito/{pool-id,client-id,discovery-url,demo-secret-arn}, targets/*-id,
lambda/*-arn, and roles/gateway-exec-arn. The demo password is never in
SSM or any file — it lives in the Secrets Manager secret
enterprise-mcp-gateway/demo-user.
Security notes
This is a sample / demonstration stack. It is deployed to a real account and is safe to demo, but it is not hardened for production — see Tracked production hardening for what to change first. Notes:
IAM — least privilege
- Per-Lambda execution roles are CDK-managed and grant only CloudWatch Logs (the sample targets touch no other AWS service).
- Gateway invoke is granted both as an identity policy on the gateway role and
as a resource-based
lambda:InvokeFunctionpermission pinned withSourceArn= this gateway's ARN (confused-deputy protection). - Gateway exec role — Cedar enforcement grants only the three documented actions
(
GetPolicyEngine,AuthorizeAction,PartiallyAuthorizeActions), not abedrock-agentcore:*wildcard. The policy engine is scoped to its exact ARN; the gateway resource stays a…:gateway/*account+region pattern because the gateway must wait for this role's policy at creation, so referencing its own ARN would be a circular dependency (see Tracked production hardening for how to tighten this in multi-gateway accounts). Note: omitting the two authorize actions breaks ENFORCE at runtime ("Insufficient Permissions for Policy Evaluation" — verified live). - Connector grants (per-user 3LO token vault + AgentCore-managed OAuth secrets) are
read-only and scoped by the
mcp-connector-*naming convention, so connectors can be added without widening the core stack. AgentCore names the managed secrets itself, so a prefix is required there; scope to exact ARNs for a fixed connector set.
Identity & secrets
- The app client enables only
ADMIN_USER_PASSWORD_AUTH(IAM-gated; not the publicUSER_PASSWORD_AUTHflow), so token minting needs no third-party SRP library. Production: federate the gateway to your own OIDC IdP (the gateway is IdP-agnostic — point its discovery URL at your IdP) and delete the demo users. - Encryption at rest uses a customer-managed KMS key (one CMK, annual rotation
enabled) for the gateway (
kmsKeyArn), the Cedar policy engine and all its policies (encryptionKeyArn), and the demo credential secret — rather than the service-managed default. The key policy follows the AgentCore requirements exactly: the gateway service role getsDescribeKey/Decrypt/GenerateDataKey/CreateGrantgated bykms:ViaServiceand a gateway-ARN encryption-context condition, while the policy engine grants go to the account principal because it callsCreateGrantthrough a Forward Access Session. Those actions are also on the gateway role's identity policy — the key policy alone is not sufficient, and omitting them fails the deploy with "no identity-based policy allows the kms:GenerateDataKey action". - The demo password is generated into AWS Secrets Manager (CMK-encrypted,
rotatable, audited) — never written to a file.
seed-demo-users.shreads it to set the users' permanent password. - Non-secret resource ids/URLs are published to SSM Parameter Store and CloudFormation outputs — there is no committed or generated state file with secrets.
- Interceptors decode the JWT without signature verification for audit attribution only (the gateway already validated the signature upstream).
Managed guardrail (Amazon Bedrock Guardrails)
The interceptors enforce a managed Amazon Bedrock Guardrail in addition to the
local regex rules, via bedrock:ApplyGuardrail:
- Request path (INPUT): prompt-attack + misconduct content filters and managed
word/topic filters; a hard
BLOCKEDshort-circuits the call. - Response path (OUTPUT): PII entities (email, phone, SSN, card, name, address)
set to ANONYMIZE, so PII is masked before it reaches the caller. The
[REDACTED_*]regex remains as a defense-in-depth backstop. - Opt-in / fail-aware: the Lambdas read
GUARDRAIL_ID/GUARDRAIL_VERSION; unset, they fall back to the local rules. A guardrail API error on the request path is logged and the local controls still apply (tune to fail-closed for stricter envs). - IAM:
bedrock:ApplyGuardrailis scoped to the single guardrail ARN; the interceptor roles otherwise hold only CloudWatch Logs.
Synthetic data
- All sample PII is synthetic:
example.comemails, the reserved555-01xxphone range, the documented invalid sample SSN123-45-6789, and the standard Visa test card4111-1111-1111-1111.DatabaseAPI___export_pii_reportintentionally returns these so the RESPONSE interceptor's redaction is provable.
Compliance considerations
This is a demonstration stack. If you adapt it for workloads handling regulated data, you are responsible for implementing controls appropriate to your compliance requirements under the AWS Shared Responsibility Model (opens in new tab) (e.g. HIPAA (opens in new tab), PCI-DSS (opens in new tab), GDPR (opens in new tab)).
Tracked production hardening (not in this sample)
- Tighten the gateway-resource IAM scope for multi-gateway accounts. The gateway
exec role's Cedar-enforcement grant is scoped to
…:gateway/*(account + region), because the gateway must wait for this role's policy at creation time — referencing the gateway's own ARN would be a circular dependency. This sample deploys a single gateway, so the wildcard effectively resolves to it. If your account runs multiple gateways in the region, restrict the statement to the specific gateway ARN after the first deploy, or use a two-phase deploy (create the gateway, then update the policy with its exact ARN). The policy engine half is already scoped to its exact ARN (PolicyEngineScoped). - Env-based config profiles —
ENFORCE+ noexceptionLevelfor prod (DEBUGreturns verbose denial reasons, useful only for a demo). - A Cognito pre-token-generation Lambda to surface
custom:rolein the access token, so the role-based Cedar policies fire (todayrole/emaillive only in the ID token, which the gateway does not validate). - Per-Lambda log retention.
Disconnect the MCP client (before teardown)
After testing — and always before you cdk destroy (a destroyed gateway leaves
a dead MCP entry that errors on every reload) — remove what the connect step added.
The artifacts also hold a live JWT, so removing them is good hygiene.
Claude Code (you registered it with connect-coding-agent.sh claude-register,
which runs claude mcp add --scope project):
claude mcp remove enterprise-gateway # removes it from .mcp.json (project scope)
claude mcp list # confirm it's gone
If you added it at user scope instead, use claude mcp remove -s user enterprise-gateway. The connect-coding-agent.sh claude (headless) mode uses a
throwaway /tmp/agc_mcp.json and registers nothing persistent — just delete that
temp file: rm -f /tmp/agc_mcp.json.
Kiro IDE (you ran connect-coding-agent.sh kiro, which wrote
.kiro/settings/mcp.json):
rm -f .kiro/settings/mcp.json # remove the workspace MCP config
# then reload MCP servers in Kiro (or reopen the workspace)
To keep Kiro configured but stop it reaching a torn-down gateway, instead edit
.kiro/settings/mcp.json and delete the enterprise-gateway entry under
mcpServers.
If you destroyed the gateway before removing the MCP entry, the dead URL stays in your client config and Kiro/Claude Code will throw a connection error (e.g. "connection refused") every time you reload MCP servers. Remove the entry immediately to stop the errors — there is nothing left to connect to.
Kiro CLI / Amazon Q (connect-coding-agent.sh kiro-cli wrote an agent file):
rm -f .kiro/agents/governance-test.json # the isolated test agent (contains a live JWT)
All of these files are git-ignored (
.mcp.json,.kiro/settings/mcp.json,.kiro/agents/governance-test.json) and hold a short-lived JWT, so deleting them is safe and leaves no trace in the repo. Re-runconnect-coding-agent.shany time to recreate them with a fresh token.
Teardown
First disconnect the MCP client (above), then destroy the stacks. If you deployed the Atlassian connector, destroy its two stacks first — they import the gateway's ids from SSM:
cd cdk
cdk destroy ConnectorAuthStack AtlassianConnectorStack --force # only if you deployed the connector
cdk destroy EnterpriseMcpGatewayStack
cd ..
Removes the entire stack (gateway, targets, policies, policy engine, Cognito pool
- secret, the 4 Lambdas, IAM roles, and the SSM parameters). The demo users seeded
by
seed-demo-users.shlive in the pool and are deleted when the pool is removed.
CloudWatch log groups are NOT removed by
cdk destroy. Lambda auto-creates/aws/lambda/<function>log groups that CloudFormation doesn't own, so they survive teardown — for every stack you deployed, and they accumulate across repeated deploys. Delete them if you want a fully clean account:for prefix in EnterpriseMcpGatewayStack AtlassianConnectorStack \ ConnectorAuthStack; do for lg in $(aws logs describe-log-groups --region "$AWS_REGION" \ --query "logGroups[?contains(logGroupName,'$prefix')].logGroupName" --output text); do aws logs delete-log-group --region "$AWS_REGION" --log-group-name "$lg"; done done
Related projects
-
Loom for AWS (opens in new tab) — an enterprise platform for building, deploying and operating agents on AgentCore Runtime and Strands: a management UI for agent lifecycle, memory, MCP server and A2A registration, AWS Agent Registry governance (approval workflow,
APPROVED-only visibility), OBO token exchange, HITL approvals, cost tracking. Loom manages which agents and MCP servers exist and who may be wired to them.This sample is complementary and sits a layer lower: it governs individual tool calls in the request path through an AgentCore Gateway — Cedar policy evaluation per call, request/response interceptor Lambdas, and a managed Bedrock Guardrail. If you want a platform, start with Loom; if you want to see enforcement at the MCP front door, start here.