Guide: Deploy a Private MCP Registry on AWS for Enterprise AI Agent Governance
This guide walks you through deploying a private MCP registry on AWS. It explains the architecture, security model, and operational procedures in detail.
Your team has 200 developers using AI coding agents. You need them all pulling from the same approved set of MCP servers — and you need to update that list in one place. That's what an MCP registry does: a single JSON file listing your approved servers, hosted at an HTTPS endpoint, consumed by every client automatically.
Kiro has built-in support for this. Point it at a registry URL, and developers only see the MCP servers you've approved. Update the file once, every client picks up the change. The governance model is simple — but the hosting question isn't.
The registry needs HTTPS with a valid certificate, and it has to be reachable from wherever your developers work. If you have on-premises infrastructure with internal CAs, an internal web server could work. But many teams — especially distributed or remote ones — don't have that readily available.
Amazon S3 paired with Amazon CloudFront is a natural fit. S3 stores the file, CloudFront serves it over HTTPS with a managed certificate and global edge caching. No servers to manage, no certificates to renew, and it costs almost nothing for a single JSON file. But there's a catch: a plain S3-plus-CloudFront setup leaves the registry URL effectively public. Anyone who discovers the URL can read your approved server list — revealing which internal tools and APIs your organization uses, hinting at technology choices, and potentially running afoul of compliance requirements that mandate access controls on configuration data.
In this post, we show you how to deploy a private MCP registry on AWS that solves both problems: globally accessible over HTTPS, but restricted to authorized users. The solution uses Amazon CloudFront, Amazon S3, and optionally AWS WAF for IP-based filtering, CloudFront signed URLs for token-based authentication, or both combined for defense-in-depth.
The complete CloudFormation template, deployment script, and sample registry file are available in the companion GitHub repository .
Understanding MCP Registries
A registry is a JSON file listing approved servers, their package sources, and transport configuration:
JSON
{"servers": [{"server": {"name": "aws-documentation","description": "Search and read AWS documentation","version": "1.0.0","packages": [{"registryType": "pypi","identifier": "awslabs.aws-documentation-mcp-server@latest","transport": {"type": "stdio","command": "uvx","args": ["awslabs.aws-documentation-mcp-server@latest"]}}]}}]}
In Kiro, an administrator configures the registry URL through enterprise governance settings . From there, developers only see servers that appear in the registry.
The registry needs to be hosted somewhere accessible over HTTPS. Your options:
- A public HTTPS endpoint (simple but no access control)
- An on-premises server (requires internal infrastructure)
- A cloud-hosted solution with access controls (the focus of this post)
Solution Overview
Our solution uses a combination of AWS services to host the registry file securely while providing controlled access. The following diagram illustrates the architecture:

Request flow:
- The MCP client requests the registry from the CloudFront URL (plain URL or signed URL with embedded credentials)
- If WAF is enabled, it checks the source IP against the allow list
- If signed URLs are enabled, CloudFront validates the signature, expiry, and key pair ID in the URL
- CloudFront receives the request and uses OAC to authenticate to S3
- S3 validates the request signature and returns the registry file
- CloudFront caches and returns the response to the client
This architecture provides several benefits:
- Security: The S3 bucket has no public access; only CloudFront can read from it
- Performance: CloudFront caches the registry at edge locations globally
- Flexibility: WAF can be enabled or disabled based on access requirements
- Cost efficiency: Pay only for what you use, with minimal baseline costs
AWS Services Used
Before diving into the implementation, let's briefly introduce the AWS services used in this solution:
Amazon S3 is an object storage service offering industry-leading scalability, data availability, security, and performance. We use it to store the registry JSON file with server-side encryption and all public access blocked.
Amazon CloudFront is a content delivery network (CDN) service that securely delivers data with low latency and high transfer speeds. It provides HTTPS endpoints, edge caching, and integration with AWS WAF for access control.
AWS WAF is a web application firewall that helps protect web applications from common web exploits. We use it to implement IP-based access control, allowing only requests from approved corporate networks.
Origin Access Control (OAC) is a CloudFront feature that provides a secure way to authenticate requests to S3 origins. It replaces the legacy Origin Access Identity (OAI) and uses AWS Signature Version 4 for request signing.
AWS Secrets Manager is a service that helps you manage, retrieve, and rotate secrets such as database credentials, API keys, and other sensitive data. In signed mode, it stores the RSA private key used to generate signed URLs and the signed URL itself, keeping both out of CloudFormation stack outputs.
AWS Lambda is a serverless compute service that runs code in response to events. A Lambda function acts as a CloudFormation custom resource to automate the signed URL key lifecycle—generating key pairs, registering them with CloudFront, and cleaning up on stack deletion.
Implementation Details
The solution is implemented as an AWS CloudFormation template that creates all required resources. The template supports three modes:
- Public mode: CloudFront serves the registry without access restrictions
- Restricted mode: WAF filters requests based on source IP addresses
- Signed mode: CloudFront signed URLs require a cryptographic token to access the registry
S3 Bucket Configuration
The S3 bucket is configured with all public access blocked and server-side encryption enabled:
YAML
MCPRegistryBucket:Type: AWS::S3::BucketProperties:BucketName: !Sub '${AWS::AccountId}-${BucketNameSuffix}'PublicAccessBlockConfiguration:BlockPublicAcls: trueIgnorePublicAcls: trueBlockPublicPolicy: trueRestrictPublicBuckets: trueBucketEncryption:ServerSideEncryptionConfiguration:- ServerSideEncryptionByDefault:SSEAlgorithm: AES256
All four
PublicAccessBlockConfiguration settings must be true to ensure complete protection. The bucket name includes the AWS account ID to ensure global uniqueness.Origin Access Control
OAC authenticates CloudFront requests to S3 using AWS Signature Version 4:
YAML
CloudFrontOAC:Type: AWS::CloudFront::OriginAccessControlProperties:OriginAccessControlConfig:Name: !Sub '${AWS::StackName}-MCP-Registry-OAC'SigningProtocol: sigv4SigningBehavior: alwaysOriginAccessControlOriginType: s3
The corresponding bucket policy allows only requests signed by your specific CloudFront distribution:
YAML
MCPRegistryBucketPolicy:Type: AWS::S3::BucketPolicyProperties:Bucket: !Ref MCPRegistryBucketPolicyDocument:Version: '2012-10-17'Statement:- Effect: AllowPrincipal:Service: cloudfront.amazonaws.comAction: s3:GetObjectResource: !Sub '${MCPRegistryBucket.Arn}/*'Condition:StringEquals:AWS:SourceArn: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}'
The
Condition block is critical—without it, any CloudFront distribution could access your bucket.CloudFront Distribution
The CloudFront distribution serves content over HTTPS with caching enabled:
YAML
CloudFrontDistribution:Type: AWS::CloudFront::DistributionProperties:DistributionConfig:Enabled: trueDefaultRootObject: mcp-registry.jsonOrigins:- Id: S3OriginDomainName: !GetAtt MCPRegistryBucket.RegionalDomainNameS3OriginConfig:OriginAccessIdentity: ''OriginAccessControlId: !GetAtt CloudFrontOAC.IdDefaultCacheBehavior:TargetOriginId: S3OriginViewerProtocolPolicy: https-onlyAllowedMethods: [GET, HEAD]CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6Compress: trueViewerCertificate:CloudFrontDefaultCertificate: trueMinimumProtocolVersion: TLSv1.2_2021WebACLId: !If- IsRestrictedMode- !GetAtt WAFWebACL.Arn- !Ref 'AWS::NoValue'
Key configuration points:
-
ViewerProtocolPolicy: https-onlyensures all traffic is encrypted
-
CachePolicyIdreferences the AWS-managed "CachingOptimized" policy
-
MinimumProtocolVersion: TLSv1.2_2021enforces modern TLS
- The
WebACLIdconditionally associates WAF when in restricted mode
WAF IP Filtering
When IP restriction is enabled, WAF resources filter requests based on source IP:
YAML
WAFIPSet:Type: AWS::WAFv2::IPSetCondition: IsRestrictedModeProperties:Name: !Sub '${AWS::StackName}-allowed-ips'Scope: CLOUDFRONTIPAddressVersion: IPV4Addresses: !Ref AllowedIPAddressesWAFWebACL:Type: AWS::WAFv2::WebACLCondition: IsRestrictedModeProperties:Name: !Sub '${AWS::StackName}-web-acl'Scope: CLOUDFRONTDefaultAction:Block:CustomResponse:ResponseCode: 403CustomResponseBodyKey: AccessDeniedBodyCustomResponseBodies:AccessDeniedBody:ContentType: APPLICATION_JSONContent: '{"error": "Access denied", "message": "Your IP address is not authorized."}'Rules:- Name: AllowListedIPsPriority: 0Action:Allow: {}Statement:IPSetReferenceStatement:Arn: !GetAtt WAFIPSet.ArnVisibilityConfig:SampledRequestsEnabled: trueCloudWatchMetricsEnabled: trueMetricName: !Sub '${AWS::StackName}-AllowListedIPs'
The
DefaultAction: Block means requests are denied unless they match an allow rule. The custom response body provides a helpful JSON error message instead of a generic 403 page.Important: WAF resources for CloudFront must use
Scope: CLOUDFRONT and be deployed in the us-east-1 Region.CloudFront Signed URL Authentication
For scenarios where IP-based filtering isn't practical—such as distributed teams, mobile workforces, or environments where users connect from unpredictable IP addresses—signed URLs offer an alternative. Instead of restricting by network location, signed URLs embed a cryptographic token directly in the registry URL. Only clients with the signed URL can access the registry.
How it works:
During deployment, a CloudFormation custom resource Lambda function:
- Generates an RSA 2048-bit key pair
- Registers the public key with CloudFront and creates a key group
- Configures the CloudFront distribution to require signed URLs via the key group
- Stores the private key in AWS Secrets Manager for safekeeping
- Generates a pre-signed URL using the private key, with a configurable expiry (default: 90 days)
- Stores the signed URL in a separate Secrets Manager secret (keeping it out of CloudFormation stack outputs)
The signed URL contains three query parameters that CloudFront validates on every request:
-
Policy— a Base64-encoded JSON policy specifying the allowed resource and expiry time
-
Signature— an RSA signature over the policy, proving the URL was issued by someone with the private key
-
Key-Pair-Id— identifies which CloudFront public key to use for signature verification
If any of these are missing, tampered with, or expired, CloudFront rejects the request with a 403.
When to use signed URLs vs. IP filtering:
Consideration | IP Filtering (WAF) | Signed URLs |
|---|---|---|
Users on fixed corporate networks | Ideal | Works, but unnecessary |
Remote/mobile workforce | IPs change frequently | Ideal |
VPN users | Works if VPN exit IPs are known | Works regardless |
Credential management | No credentials to manage | URL must be treated as a secret |
Revocation | Update IP list | Redeploy stack or delete resources |
Defense in depth | Single layer | Can combine with WAF |
Combining signed URLs with WAF:
For maximum security, you can enable both signed URLs and IP filtering simultaneously. This defense-in-depth approach requires requests to both originate from an approved IP and carry a valid signed token:
Bash
./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json \--mode signed \--allowed-ips "10.0.0.0/8,203.0.113.0/24"
Security considerations for signed URLs:
- Treat the signed URL as a secret credential—do not commit it to version control or share it in plaintext
- The private key is stored in AWS Secrets Manager and never leaves your account
- To revoke access, delete the stack or redeploy in public mode, which removes the key group and invalidates all signed URLs
- Consider using a shorter expiry for higher-security environments
Determining Which IP Addresses to Allow
When configuring IP-based access control, you need to identify the public IP addresses from which your users will access the registry. The MCP client (running on developer workstations) makes HTTPS requests to CloudFront, so you need to allowlist the public IPs that these requests originate from.
Common IP sources to consider:
Scenario | IP Source | How to Find |
|---|---|---|
Office network | Corporate NAT gateway or firewall egress IP | Contact your network team or check "what is my IP" from an office machine |
VPN users | VPN concentrator exit IPs | Contact your IT/security team for VPN egress ranges |
Remote workers (no VPN) | ISP-assigned IPs | Varies per user—consider VPN requirement instead |
Cloud workstations (EC2, Cloud9) | NAT Gateway or Elastic IP | Check VPC configuration or instance metadata |
AWS Lambda or containers | NAT Gateway IPs for the VPC | Check your VPC's NAT Gateway Elastic IPs |
Finding your current public IP:
Bash
# From any machine, check its public IPcurl -s https://checkip.amazonaws.com# Or use other servicescurl -s https://ifconfig.mecurl -s https://ipinfo.io/ip
IP format (CIDR notation):
WAF requires IP addresses in CIDR notation:
- Single IP:
203.0.113.50/32
- Small range:
203.0.113.0/24(256 addresses: 203.0.113.0 - 203.0.113.255)
- Large range:
10.0.0.0/8(16 million addresses: 10.0.0.0 - 10.255.255.255)
Example configurations:
Bash
# Single office with one public IP--allowed-ips "203.0.113.50/32"# Multiple offices--allowed-ips "203.0.113.0/24,198.51.100.0/24"# Corporate network plus VPN exit points--allowed-ips "10.0.0.0/8,203.0.113.0/24,198.51.100.10/32"
Tip: Start with a narrow allowlist and expand as needed. You can update the IP Set after deployment without recreating the entire stack.
Deployment
Bash
# Public mode (no access restrictions)./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json \--mode public \--region us-east-1# Restricted mode (IP filtering enabled)./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json \--mode restricted \--allowed-ips "10.0.0.0/8,192.168.1.0/24" \--region us-east-1# Signed mode (CloudFront signed URL authentication)./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json \--mode signed \--region us-east-1# Signed mode with IP filtering (defense-in-depth)./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json \--mode signed \--allowed-ips "10.0.0.0/8,192.168.1.0/24" \--region us-east-1
The deployment script:
- Deploys or updates the CloudFormation stack
- Validates the registry JSON file against the MCP schema
- Uploads the registry JSON file to S3
- Creates a CloudFront cache invalidation to propagate changes
After deployment, the script outputs the registry URL. In public or restricted mode:
Plain text
MCP Registry URL: https://d1234567890.cloudfront.net/mcp-registry.json
In signed mode, the script also outputs the signed URL:
Plain text
⚠️ SIGNED URL (TREAT AS SECRET CREDENTIAL):https://d1234567890.cloudfront.net/mcp-registry.json?Expires=...&Signature=...&Key-Pair-Id=...⚠️ WARNING: This URL contains embedded authentication credentials.Do NOT share it publicly. Treat it like a password.
Configure the appropriate URL in your MCP client to use your private registry.
Signed URL Rotation
When using signed URLs, you should establish a rotation schedule. The default expiry is 90 days, but shorter expiries (e.g., 30 days) are recommended for higher-security environments. Regardless of expiry, periodic rotation limits the window of exposure if a URL is leaked.
Quick Rotation via Redeployment
The simplest way to rotate is to redeploy the stack. The Lambda custom resource already handles the full key lifecycle — generating a new key pair, registering it with CloudFront, storing the private key in Secrets Manager, and producing a new signed URL. Just run:
Bash
./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json \--mode signed
The stack update replaces the old key group with a new one and outputs a fresh signed URL. Update the URL in your AI assistant's admin console (e.g., Kiro enterprise governance settings), and clients pick it up automatically.
There is a brief moment during the update where the old key group is removed before the new one is active. For a registry JSON file, this is typically acceptable — clients that retry will succeed within seconds. If your environment requires strict zero-downtime rotation, see the manual procedure below.
Zero-Downtime Rotation with Dual Key Groups
For environments where even brief interruptions are unacceptable, CloudFront supports up to four trusted key groups simultaneously. You can add a second key group alongside the existing one, update the admin console, and then remove the old key group — ensuring both URLs are valid during the transition.
Phase | TrustedKeyGroups | Status |
|---|---|---|
1. Normal operation | Key Group A | Old URL works |
2. Overlap period | Key Group A + Key Group B | Both URLs work |
3. Admin console update | Key Group A + Key Group B | Update admin console with new URL |
4. Cleanup | Key Group B | Old URL revoked |
Step 1 — Generate a new key pair and signed URL:
Bash
# Generate new RSA key pairopenssl genrsa -out new-private.pem 2048openssl rsa -in new-private.pem -pubout -out new-public.pem# Register the new public key with CloudFrontaws cloudfront create-public-key --public-key-config '{"CallerReference": "rotation-'$(date +%Y%m%d%H%M%S)'","Name": "my-registry-rotation-key","EncodedKey": "'"$(cat new-public.pem)"'"}'# Note the returned PublicKey.Id# Create a new key group with the new public keyaws cloudfront create-key-group --key-group-config '{"Name": "my-registry-rotation-group","Items": ["<NEW_PUBLIC_KEY_ID>"]}'# Note the returned KeyGroup.Id
Step 2 — Add the new key group to the distribution (overlap period begins):
Bash
# Get current configaws cloudfront get-distribution-config --id <DIST_ID> > config.json# Edit config.json: In DefaultCacheBehavior.TrustedKeyGroups,# change Quantity to 2 and add the new key group ID to Items array# Apply the updateaws cloudfront update-distribution --id <DIST_ID> \--distribution-config file://config.json --if-match <ETAG>
At this point, both the old and new signed URLs are valid.
Step 3 — Update the registry URL. Generate a signed URL using the new private key and update it in your AI assistant's admin console. Since the URL is configured centrally, this is a single configuration change.
Step 4 — Remove the old key group:
Bash
# Edit config.json: Remove old key group ID, set Quantity back to 1aws cloudfront update-distribution --id <DIST_ID> \--distribution-config file://config.json --if-match <ETAG># Clean up old resourcesaws cloudfront delete-key-group --id <OLD_KG_ID> --if-match <ETAG>aws cloudfront delete-public-key --id <OLD_PK_ID> --if-match <ETAG>aws secretsmanager delete-secret \--secret-id <STACK_NAME>-signing-key \--force-delete-without-recovery# Store new private key and clean up local filesaws secretsmanager create-secret \--name <STACK_NAME>-signing-key \--secret-string file://new-private.pemrm new-private.pem new-public.pem
Emergency Revocation
If a signed URL is compromised, you can revoke it immediately by redeploying in public mode, then redeploying in signed mode to generate a fresh URL:
Bash
# Immediate revocation: switch to public mode (removes all key groups)./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json --mode public# Then redeploy with a fresh signed URL./deploy.sh --stack-name my-mcp-registry \--registry-file ./mcp-registry.json --mode signed
This invalidates the compromised URL within minutes (after CloudFront propagation). Update the admin console with the new signed URL to restore access.
Recommended Rotation Schedule
Environment | Expiry | Rotation Frequency |
|---|---|---|
Development | 365 days | Every 6 months |
Production | 90 days | Every 60 days |
High security | 30 days | Every 21 days |
Best Practices
Registry management:
- Store your
mcp-registry.jsonin version control
- Implement a review process for adding or updating servers
- Validate the registry against the MCP schema before deployment
Security:
- Use restricted mode for registries containing internal tool information
- Use signed mode when your users connect from unpredictable IP addresses
- Combine signed mode with WAF IP filtering for defense-in-depth
- Include VPN exit points in your IP allow list if users connect via VPN
- Regularly audit the allowed IP ranges
- Treat signed URLs as secret credentials—distribute them through secure channels only
- Store signed URLs in secrets managers or encrypted configuration, never in version control
Operations:
- Enable CloudFront access logs for visibility into registry access
- Set up CloudWatch alarms for error rates
- Use the deployment script's cache invalidation to ensure updates propagate quickly
Conclusion
Deploying a private MCP registry on AWS provides the governance layer enterprises need for AI agent tool management. By combining CloudFront, S3, and WAF, organizations can host a curated catalog of approved MCP servers that's accessible only to authorized users.
This solution offers:
- Security: Private S3 bucket with OAC authentication
- Performance: Global edge caching via CloudFront
- Flexibility: IP-based access control via WAF, token-based access via signed URLs, or both combined
- Cost efficiency: For a single JSON file with moderate traffic, costs are minimal. For detailed and up-to-date pricing, see the individual service pricing pages or use the AWS Pricing Calculator to estimate costs for your specific usage:
The complete CloudFormation template and deployment scripts are available in the companion GitHub repository . For more information about MCP and building AI agents, see the Model Context Protocol specification and Amazon Bedrock AgentCore documentation .
About the Authors
Mohammed Issa is a Principal Customer Solutions Manager at AWS based in Sweden. Bringing long expertise in AWS cloud services, developer experience, DevOps, AI/ML and enterprise transformation, he helps customers adopt complex modern technologies and drive business value from their technology investments. Outside work, he is a husband and a father of 2 sons, and enjoys movies, football, and hanging out with friends.
Bastian Töpfer is a Senior Solutions Architect at AWS with over 20 years of experience developing and architecting software solutions for enterprises and start-ups across telecom, healthcare, automotive, and social media. He works with customers to design, implement, and optimize cloud-based solutions, leveraging expertise in infrastructure, software development, and machine learning to deliver scalable, secure, and cost-effective outcomes. Bastian is passionate about learning new technologies and sharing his knowledge with others.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article