MCP servers don't always need containers, persistent processes, or a full web framework.
For many tools, a regular Python AWS Lambda is enough:
MCP request
β
API Gateway
β
Lambda
β
Python tool
β
JSON-RPC response
But some operations are different.
A tool may spend 30 seconds searching, analyzing, or coordinating work and need to report progress while it runs.
For those workloads we need real Streamable HTTP:
tools/call
β
progress
β
progress
β
progress
β
final result
We wanted both models in Python without requiring applications to adopt different MCP programming models.
That led to two open-source projects:
Together they let a Python application start with a lightweight buffered MCP server and opt into real Lambda response streaming when the workload actually needs it.
The simplest deployment doesn't require Lambda Web Adapter or response streaming.
Install modmex-lambda
:
pip install modmex-lambda
Create an MCP server:
from modmex_lambda import APIGatewayHttpResolver
from modmex_lambda.mcp import MCPServer
mcp = MCPServer(
name="orders",
version="1.0.0",
)
Then expose capabilities as regular Python functions.
@mcp.tool()
def get_order(order_id: str) -> dict:
return {
"id": order_id,
"status": "confirmed",
}
php
@mcp.resource("orders://{order_id}")
def order_resource(order_id: str) -> dict:
return load_order(order_id)
python
@mcp.prompt()
def order_assistant(customer_id: str):
return build_order_prompt(customer_id)
Mount the MCP server on the normal API Gateway resolver:
app = APIGatewayHttpResolver()
app.include_mcp(
mcp,
path="/mcp",
)
handler = app.handler
The resulting architecture is deliberately boring:
MCP Client
β
API Gateway HTTP API v2
β
AWS Lambda
β
modmex-lambda
β
MCPServer
For short-lived tools, resources, and prompts, that's usually exactly what we want.
No FastAPI.
No Flask.
No ASGI server.
No response-streaming infrastructure.
Just Python and Lambda.
One of the design goals was not to create a separate application architecture for MCP.
Tools can use the same dependency injection mechanisms as regular Lambda endpoints:
@mcp.tool()
def get_order(
order_id: str,
service: Annotation[OrderService, Depends()],
):
return service.get(order_id)
That means REST and MCP can remain thin interfaces over the same application services:
OrderService
β²
β
ββββββββββββ΄βββββββββββ
β β
REST API MCP
The same idea applies to middleware.
Authorization, tenant resolution, logging, auditing, tracing, and policies don't need to be implemented inside every tool:
@mcp.tool(
middlewares=[
RequirePermission("orders:read"),
]
)
def get_order(...):
...
MCP becomes another transport into the application rather than another application architecture.
It's easy to associate MCP with streaming, but many MCP operations don't benefit from it.
Consider:
get_customer
get_order
calculate_route
lookup_inventory
read_resource
get_prompt
If a tool finishes in 300 milliseconds or two seconds, a normal Lambda response is simpler.
For this reason, streaming isn't a requirement in modmex-lambda
.
You can run MCP through a regular managed Python Lambda and API Gateway HTTP API v2.
That gives us the first deployment model:
Python
+
modmex-lambda
+
Lambda
+
HTTP API v2
Then, when a workload actually needs incremental communication, we can move to the second model.
Consider a tool that performs several expensive steps:
@mcp.tool()
def analyze_market(ctx: MCPContext):
opportunities = search_opportunities()
ranked = rank_opportunities(opportunities)
analysis = analyze_market_conditions(ranked)
return build_recommendation(analysis)
Maybe the complete operation takes 30 or 40 seconds.
With a buffered response, the MCP client sees nothing until the function finishes.
Instead, we want the tool to report progress:
@mcp.tool()
def analyze_market(ctx: MCPContext):
ctx.progress.report(
1,
total=4,
message="Searching opportunities",
)
opportunities = search_opportunities()
ctx.progress.report(
2,
total=4,
message="Ranking candidates",
)
ranked = rank_opportunities(opportunities)
ctx.progress.report(
3,
total=4,
message="Analyzing market conditions",
)
analysis = analyze_market_conditions(ranked)
ctx.progress.report(
4,
total=4,
message="Building recommendation",
)
return build_recommendation(analysis)
Those progress reports are translated into MCP notifications/progress
messages and sent over the same Streamable HTTP response before the final JSON-RPC result.
Now we need real response streaming.
For streaming, modmex-lambda
provides LambdaWebAdapterResolver
.
The application remains Python:
from modmex_lambda import LambdaWebAdapterResolver
from modmex_lambda.mcp import MCPServer
mcp = MCPServer(
name="orders",
version="1.0.0",
)
app = LambdaWebAdapterResolver()
app.include_mcp(
mcp,
path="/mcp",
)
handler = app.handler
The infrastructure changes underneath it:
MCP Client
β
API Gateway REST API
β
responseTransferMode = STREAM
β
AWS Lambda
β
Lambda Web Adapter
β
Python application
β
modmex-lambda
Lambda Web Adapter connects the HTTP response produced by the Python application with Lambda response streaming.
Now an MCP tool can emit progress while it is still running:
0s tools/call
β
4s notifications/progress
"Searching opportunities"
β
12s notifications/progress
"Ranking candidates"
β
21s notifications/progress
"Analyzing market conditions"
β
30s final JSON-RPC result
The Lambda invocation hasn't completed when those progress messages reach the MCP client.
That's real incremental MCP streaming.
Getting streaming to work inside Python is only part of the job.
A streaming Lambda deployment also needs the right infrastructure:
Lambda Web Adapter
response streaming mode
API Gateway REST API
STREAM transfer mode
launcher configuration
architecture-specific adapter layer
packaging
We didn't want every Python MCP service to reproduce that configuration manually.
That's why we built serverless-python-mcp
.
Install it:
npm install --save-dev serverless-python-mcp
and register it like any other Serverless Framework plugin:
plugins:
- serverless-python-mcp
MCP servers are declared under custom.pythonMcp.servers
.
For a normal buffered MCP server:
custom:
pythonMcp:
servers:
orders:
handler: app.handler
transport: httpApi
streaming: false
The application uses:
app = APIGatewayHttpResolver()
app.include_mcp(mcp, path="/mcp")
handler = app.handler
The plugin creates a normal Lambda behind API Gateway HTTP API v2.
No Lambda Web Adapter is added.
No streaming launcher is added.
This remains the lightweight deployment path.
When the same class of application needs real streaming:
custom:
pythonMcp:
servers:
orders:
handler: app.handler
transport: http
streaming: true
The Python application switches to:
app = LambdaWebAdapterResolver()
app.include_mcp(mcp, path="/mcp")
handler = app.handler
The plugin takes care of the AWS-specific pieces required for streaming.
It attaches the architecture-specific Lambda Web Adapter layer, configures the execution wrapper and streaming mode, creates the launcher used by the HTTP process, and configures the REST API integration for streaming.
The developer still works with a Python MCP server.
serverless-python-mcp
currently supports three AWS front doors:
| Transport | AWS front door | Buffered | Streaming |
|---|---|---|---|
httpApi |
|||
| API Gateway HTTP API v2 | Yes | No | |
http |
|||
| API Gateway REST API v1 | Yes | Yes | |
url |
|||
| Lambda Function URL | Yes | Yes |
This lets the infrastructure match the workload.
For a simple internal MCP service:
transport: httpApi
streaming: false
For an MCP server that needs API Gateway capabilities and real streaming:
transport: http
streaming: true
And for cases where a Function URL is sufficient:
transport: url
streaming: true
The application doesn't need a new MCP abstraction for each one.
Streaming and transport are intentionally separate choices.
For example:
custom:
pythonMcp:
servers:
orders:
handler: app.handler
transport: http
streaming: false
uses API Gateway REST API but invokes the Python Lambda normally.
The application uses:
app = APIGatewayRestResolver()
This can be useful when REST API features are desired but incremental MCP streaming isn't.
The deployment model therefore isn't simply:
HTTP API = simple
REST API = streaming
It's more accurately:
Buffered Streaming
HTTP API v2 β -
REST API v1 β β
Function URL β β
Each server can expose its own path:
custom:
pythonMcp:
servers:
orders:
handler: orders.handler
transport: http
streaming: true
path: /orders/mcp
inventory:
handler: inventory.handler
transport: http
streaming: true
path: /inventory/mcp
The Python application registers the same path:
app.include_mcp(
mcp,
path="/orders/mcp",
)
Servers using API Gateway can share the underlying API while keeping separate Lambda functions and MCP endpoints.
That makes it possible to expose multiple domain capabilities without building one giant MCP server.
The plugin also deliberately doesn't invent an MCP-specific authentication model.
For HTTP API, existing Serverless authorizer configuration can be used.
For REST API, the plugin passes authorizer configuration through to the normal Serverless REST API event compiler.
Function URLs can use their supported public or AWS IAM modes.
So the architecture remains:
MCP Client
β
AWS authentication / authorizer
β
MCP transport
β
middleware / application authorization
β
tool
This keeps authentication independent from the MCP programming model.
An important lesson from building this was that streaming shouldn't become the default architecture just because MCP supports it.
For many servers:
HTTP API v2
+
Lambda
+
modmex-lambda
is enough.
It's lightweight and fits the serverless execution model extremely well.
Streaming becomes useful when the operation actually has intermediate information worth delivering.
Then we can move to:
REST API
+
Lambda response streaming
+
Lambda Web Adapter
+
modmex-lambda
without redesigning tools, resources, prompts, middleware, or application services.
There is one serverless behavior worth understanding.
A client disconnect doesn't guarantee that the Lambda invocation immediately stops.
There are multiple network boundaries between the MCP client and the Python process:
MCP Client
β
API Gateway
β
Lambda
β
Lambda Web Adapter
β
Python
When the transport can observe a disconnect, modmex-lambda
can propagate cooperative cancellation through MCPContext
.
A long-running tool can therefore check:
if ctx.cancelled:
return {"status": "cancelled"}
But applications shouldn't assume that every downstream network failure will immediately terminate a running Lambda invocation.
Response streaming and distributed execution cancellation are separate concerns.
The final architecture looks like this:
MCPServer
β
ββββββββββββββββ΄βββββββββββββββ
β β
βΌ βΌ
Buffered execution Streamable execution
β β
regular Lambda HTTP process
β β
βΌ βΌ
HTTP API / REST / URL Lambda Web Adapter
β
βΌ
Lambda streaming
The important part is what doesn't change:
tools
resources
prompts
dependency injection
middleware
application services
Streaming is an infrastructure capability, not a new application architecture.
The complete implementation is available in two projects:
modmex-lambda contains the Python MCP runtime and application integration.
It provides the MCP server, tools, resources, prompts, middleware, dependency injection, protocol validation, buffered HTTP transports, and Streamable HTTP support.
serverless-python-mcp provides the Serverless Framework deployment integration.
It creates MCP Lambda functions from custom.pythonMcp.servers
and configures the appropriate AWS front door and runtime behavior for buffered or streaming execution.
The design goal behind both projects is straightforward:
You don't need streaming to run MCP on Lambda. But when you need it, you shouldn't have to rewrite your MCP server.
Start with the smallest architecture that works.
Add streaming when the workload earns the complexity.
Keep the Python application the same.