Ship a Remote MCP Server on AWS Lambda: Python, IAM Auth, Near-Zero Idle Cost
A walkthrough of deploying a remote MCP server on AWS Lambda with a function URL, IAM auth and AWS SAM, so any agent can call your tools with near-zero idle cost.
Most MCP tutorials end with a server on
localhost talking to one desktop app. That's fine for experiments. But once I started building agents for actual users, I wanted my tools to live somewhere every agent could reach. That meant one deployment, real auth, logs, and no laptop that has to stay awake.This post walks through what I ended up using: a Python MCP server on AWS Lambda, exposed through a Lambda function URL, protected by IAM, and deployed with AWS SAM. By the end you'll have two working tools that Claude Code, Kiro, or your own Python agent can call.
Why put MCP servers in the cloud?
The Model Context Protocol (MCP) gives agents one standard way to discover and call tools. Over stdio, every client spawns its own copy of your server. A remote server, using the Streamable HTTP transport, changes that:
- One source of truth. Update the tool once, and every agent gets the new version.
- Real credentials stay server-side. The agent never sees your database keys. It only sees the tool.
- Pay per call. Agent traffic is bursty, and that suits Lambda. When nobody is calling, nothing runs.
Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
Agent / MCP client
| stdio
v
mcp-proxy-for-aws (local, signs requests with SigV4)
| HTTPS + SigV4, Streamable HTTP
v
Lambda function URL (AuthType: AWS_IAM)
|
v
Lambda: Lambda Web Adapter -> uvicorn -> MCP server
| IAM role: GetItem/PutItem on one table
v
DynamoDB tableThe trick is the AWS Lambda Web Adapter . The MCP Python SDK gives you a normal ASGI app, and the adapter runs it inside Lambda unchanged. You don't write a custom handler or translate events.
The server
Project layout:
1
2
3
4
5
6
notes-mcp/
|-- template.yaml
`-- app/
|-- server.py
|-- run.sh
`-- requirements.txtapp/server.py:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import os
import time
import uuid
import boto3
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from pydantic import BaseModel
table = boto3.resource("dynamodb").Table(os.environ["NOTES_TABLE"])
mcp = MCPServer("notes")
class Note(BaseModel):
id: str
text: str
created_at: int
def save_note(text: str) -> Note:
note = Note(id=uuid.uuid4().hex[:12], text=text[:2000], created_at=int(time.time()))
table.put_item(Item=note.model_dump())
return note
def get_note(note_id: str) -> Note:
item = table.get_item(Key={"id": note_id}).get("Item")
if not item:
raise ValueError(f"No note with id {note_id}")
return Note(id=item["id"], text=item["text"], created_at=int(item["created_at"]))
app = mcp.streamable_http_app(
stateless_http=True,
json_response=True,
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)Three choices here matter on Lambda:
stateless_http=True: Lambda can send each request to a different execution environment, so the server shouldn't depend on in-memory sessions. Any state goes in DynamoDB.json_response=True: the server returns plain JSON instead of an SSE stream. That works with Lambda's default buffered responses.transport_security: by default the SDK accepts onlylocalhostHost headers, so a deployed app answers every request with 421 Misdirected Request. I turn the check off here because IAM is the gate and no browser is involved. If you put this behind a custom domain, useallowed_hosts=[...]instead.
Returning a Pydantic model gives the tool an output schema and structured results, so agents get typed data back instead of a string they have to parse.
app/run.sh (run chmod +x app/run.sh):1
2
3
4
PATH=$PATH:$LAMBDA_TASK_ROOT/bin \
PYTHONPATH=$PYTHONPATH:/opt/python:$LAMBDA_RUNTIME_DIR \
exec python -m uvicorn --port=$AWS_LWA_PORT server:appapp/requirements.txt:1
2
mcp==2.3.0
uvicornHeads-up: version 2 of the MCP Python SDK renamedFastMCPtoMCPServer. If you're on 1.x, the same ideas apply withfrom mcp.server.fastmcp import FastMCP. Pin your version either way.
Deploy with AWS SAM
template.yaml:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Notes MCP server on AWS Lambda
Resources:
NotesTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
McpFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: app/
Handler: run.sh
Runtime: python3.13
Architectures:
- arm64
MemorySize: 512
Timeout: 30
Layers:
- !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerArm64:30
Environment:
Variables:
AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
AWS_LWA_PORT: "8000"
NOTES_TABLE: !Ref NotesTable
Policies:
- Statement:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
Resource: !GetAtt NotesTable.Arn
FunctionUrlConfig:
AuthType: AWS_IAM
Outputs:
McpEndpoint:
Value: !Sub "${McpFunctionUrl.FunctionUrl}mcp"
FunctionArn:
Value: !GetAtt McpFunction.ArnThen:
1
2
3
chmod +x app/run.sh
sam build
sam deploy --guidedCopy the
McpEndpoint output. It looks like https://<id>.lambda-url.<region>.on.aws/mcp. The layer version above was the latest one when I wrote this, so check the Lambda Web Adapter docs for the current ARN.Connect a client or agent
Because the URL needs SigV4-signed requests, most MCP clients can't call it directly. AWS publishes MCP Proxy for AWS . It runs locally as a stdio server, signs each request with your AWS credentials, and forwards it.
Claude Code:
1
2
3
claude mcp add notes -- uvx mcp-proxy-for-aws-cli@latest \
https://<id>.lambda-url.ap-southeast-1.on.aws/mcp \
--service lambda --region ap-southeast-1Kiro, Cursor, or any client with an
mcpServers JSON config:1
2
3
4
5
6
7
8
9
10
11
12
13
{
"mcpServers": {
"notes": {
"command": "uvx",
"args": [
"mcp-proxy-for-aws-cli@latest",
"https://<id>.lambda-url.ap-southeast-1.on.aws/mcp",
"--service", "lambda",
"--region", "ap-southeast-1"
]
}
}
}Your own Python agent (
pip install mcp-proxy-for-aws):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import asyncio
import sys
from mcp import ClientSession
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
async def main(url, region):
async with aws_iam_streamablehttp_client(
endpoint=url, aws_service="lambda", aws_region=region
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("save_note", {"text": "Called from my agent"})
print(result.structuredContent)
asyncio.run(main(sys.argv[1], sys.argv[2]))Set
--service lambda explicitly. If the proxy can't infer the service from a function URL hostname, signing fails with a 403.Security: who can call what
There are two separate IAM questions, so treat them separately.
1. What can the function do? The function role above can call
GetItem and PutItem on one table and nothing else. Even if a prompt-injected agent sends strange arguments, the worst it can do is read or write notes. Every new tool should get its own minimal permissions.2. Who can invoke the function? With
AuthType: AWS_IAM, only signed requests from principals you allow will get through. Since October 2025, new function URLs require both lambda:InvokeFunctionUrl and lambda:InvokeFunction. Give the calling role or user something like this:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:ap-southeast-1:123456789012:function:YOUR_FUNCTION",
"Condition": { "StringEquals": { "lambda:FunctionUrlAuthType": "AWS_IAM" } }
},
{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:ap-southeast-1:123456789012:function:YOUR_FUNCTION",
"Condition": { "Bool": { "lambda:InvokedViaFunctionUrl": "true" } }
}
]
}A few more habits I follow:
- Treat tool arguments as untrusted input. An LLM chose them. Validate and cap them, as
text[:2000]does. readOnlyHintis a hint for clients, not a security control. Enforcement comes from IAM.- Put secrets in AWS Secrets Manager or SSM Parameter Store, not in plain environment variables.
- Set reserved concurrency on the function to cap the blast radius and the bill if an agent gets stuck in a loop.
- SigV4 works well for service-to-service and developer use. If end users need to connect from clients that only speak OAuth, look at API Gateway with a JWT authorizer, or Amazon Bedrock AgentCore (Runtime or Gateway), which supports OAuth-based inbound auth for MCP.
Costs
For a personal or small-team tool server, this is close to free. Function URLs don't cost extra, Lambda bills per request and per GB-second, and on-demand DynamoDB bills per read and write. Lambda's monthly free allowance covers a lot of agent calls, and arm64 is cheaper per GB-second than x86. The real costs are usually CloudWatch Logs if you log verbosely, plus whatever downstream APIs your tools call.
Gotchas I hit (so you don't have to)
- 421 Misdirected Request: the SDK's Host check. Configure
transport_security. - The function never becomes ready:
run.shisn't executable, or the port doesn't matchAWS_LWA_PORT. - 403 with valid credentials: the caller is missing
lambda:InvokeFunction, or you signed with the wrong service or region. - Cold starts: the first call after idle takes noticeably longer. Agents usually tolerate it, but keep imports light and consider provisioned concurrency for latency-sensitive tools.
- No server-to-client features: stateless JSON mode rules out sampling, elicitation pushes, and long streaming responses. For those, look at response streaming or a long-running host such as AgentCore Runtime, ECS, or App Runner.
- Limits still apply: Lambda's timeout and response payload limits apply to every tool call, so return summaries, not huge blobs.
Wrapping up
Starting from a working local server, getting it onto Lambda took me about forty lines of Python, a short shell script, and a SAM template. Every agent I build now gets the same tools, scoped by IAM. From here, add tools one at a time with minimal permissions, and write clear tool descriptions. The model reads them like a prompt.
If you build something with this, I'd like to hear what tools you're exposing. Drop a comment below.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article