AWS MCP Server, Guarded by Okta
I wanted to be able to ask Claude questions about my own AWS account directly, instead of copy-pasting aws cli output into a chat window. So I built a remote MCP (Model Context Protocol) server that Claude connects to over HTTPS, gated behind Okta so that only I can use it.
The server itself is a single Lambda function behind an API Gateway HTTP API at mcp.aceraney.com, written in Python with Starlette and wrapped for Lambda with Mangum. Rather than hand-writing a tool per AWS resource type, it exposes one generic tool to Claude, call_aws_api, which takes a boto3 service name, an operation name, and a params dict, checks that the operation starts with Describe, List, or Get, and dispatches it. Two more tools, list_services and list_operations, let Claude discover what it's allowed to call on its own. The real read-only boundary is enforced by IAM, not application code — the Lambda's execution role only has the AWS managed ReadOnlyAccess policy attached, so the allowlist in the code is just defense in depth.
The tool dispatcher
READ_ONLY_PATTERN = re.compile(r"^(Describe|List|Get)")
def call_aws_api(service, operation, params=None, region=None):
if not READ_ONLY_PATTERN.match(operation):
raise ValueError(
f"Operation '{operation}' is not allowed; only "
"Describe*/List*/Get* operations are permitted"
)
client = boto3.client(service, region_name=region) if region else boto3.client(service)
method_name = _method_name_for_operation(client, operation)
result = getattr(client, method_name)(**(params or {}))
result.pop("ResponseMetadata", None)
return result
Okta, and the OAuth broker problem
The more interesting problem was authentication. Claude's remote MCP connectors expect the server to support OAuth with Dynamic Client Registration (DCR) — Claude wants to register itself as an OAuth client on the fly, the first time you add the connector. Okta doesn't offer open, tokenless DCR, so instead the Lambda itself acts as a thin OAuth broker: it answers Claude's register/authorize/token calls, but always hands back the same static client id, and quietly federates the actual login to one real, pre-created Okta OIDC app via a normal Authorization Code flow.
A small DynamoDB table holds the short-lived state for that handshake — pending authorize requests and one-time codes — with TTL cleaning up anything abandoned. Once the flow finishes, the access token Claude ends up holding is the real Okta-issued JWT, which API Gateway validates natively via a JWT authorizer against Okta's issuer and audience. The Lambda itself never has to verify a token; a bad or expired one never even reaches it.
If you want to see the Terraform and Python behind this, it's in the same repo as this site, here.
Thanks!