DEV Community

Alejandro Hernández
Alejandro Hernández

Posted on

Building Lightweight and Streamable MCP Servers on AWS Lambda with Python

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Start with a regular Python Lambda

The simplest deployment doesn't require Lambda Web Adapter or response streaming.

Install modmex-lambda:

pip install modmex-lambda
Enter fullscreen mode Exit fullscreen mode

Create an MCP server:

from modmex_lambda import APIGatewayHttpResolver
from modmex_lambda.mcp import MCPServer

mcp = MCPServer(
    name="orders",
    version="1.0.0",
)
Enter fullscreen mode Exit fullscreen mode

Then expose capabilities as regular Python functions.

Tools

@mcp.tool()
def get_order(order_id: str) -> dict:
    return {
        "id": order_id,
        "status": "confirmed",
    }
Enter fullscreen mode Exit fullscreen mode

Resources

@mcp.resource("orders://{order_id}")
def order_resource(order_id: str) -> dict:
    return load_order(order_id)
Enter fullscreen mode Exit fullscreen mode

Prompts

@mcp.prompt()
def order_assistant(customer_id: str):
    return build_order_prompt(customer_id)
Enter fullscreen mode Exit fullscreen mode

Mount the MCP server on the normal API Gateway resolver:

app = APIGatewayHttpResolver()

app.include_mcp(
    mcp,
    path="/mcp",
)

handler = app.handler
Enter fullscreen mode Exit fullscreen mode

The resulting architecture is deliberately boring:

MCP Client
    ↓
API Gateway HTTP API v2
    ↓
AWS Lambda
    ↓
modmex-lambda
    ↓
MCPServer
Enter fullscreen mode Exit fullscreen mode

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.

MCP is another interface to the application layer

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)
Enter fullscreen mode Exit fullscreen mode

That means REST and MCP can remain thin interfaces over the same application services:

                 OrderService
                     ▲
                     │
          ┌──────────┴──────────┐
          │                     │
       REST API                MCP
Enter fullscreen mode Exit fullscreen mode

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(...):
    ...
Enter fullscreen mode Exit fullscreen mode

MCP becomes another transport into the application rather than another application architecture.

Why buffered MCP is useful

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then, when a workload actually needs incremental communication, we can move to the second model.

When buffered responses stop being enough

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Real MCP streaming from Python Lambda

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
Enter fullscreen mode Exit fullscreen mode

The infrastructure changes underneath it:

MCP Client
    ↓
API Gateway REST API
    ↓
responseTransferMode = STREAM
    ↓
AWS Lambda
    ↓
Lambda Web Adapter
    ↓
Python application
    ↓
modmex-lambda
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Lambda invocation hasn't completed when those progress messages reach the MCP client.

That's real incremental MCP streaming.

Deployment is the second half of the problem

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and register it like any other Serverless Framework plugin:

plugins:
  - serverless-python-mcp
Enter fullscreen mode Exit fullscreen mode

MCP servers are declared under custom.pythonMcp.servers.

Lightweight deployment with HTTP API v2

For a normal buffered MCP server:

custom:
  pythonMcp:
    servers:
      orders:
        handler: app.handler
        transport: httpApi
        streaming: false
Enter fullscreen mode Exit fullscreen mode

The application uses:

app = APIGatewayHttpResolver()
app.include_mcp(mcp, path="/mcp")

handler = app.handler
Enter fullscreen mode Exit fullscreen mode

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.

Streamable deployment with REST API

When the same class of application needs real streaming:

custom:
  pythonMcp:
    servers:
      orders:
        handler: app.handler
        transport: http
        streaming: true
Enter fullscreen mode Exit fullscreen mode

The Python application switches to:

app = LambdaWebAdapterResolver()
app.include_mcp(mcp, path="/mcp")

handler = app.handler
Enter fullscreen mode Exit fullscreen mode

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.

Three deployment front doors

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
Enter fullscreen mode Exit fullscreen mode

For an MCP server that needs API Gateway capabilities and real streaming:

transport: http
streaming: true
Enter fullscreen mode Exit fullscreen mode

And for cases where a Function URL is sufficient:

transport: url
streaming: true
Enter fullscreen mode Exit fullscreen mode

The application doesn't need a new MCP abstraction for each one.

REST API without streaming is supported too

Streaming and transport are intentionally separate choices.

For example:

custom:
  pythonMcp:
    servers:
      orders:
        handler: app.handler
        transport: http
        streaming: false
Enter fullscreen mode Exit fullscreen mode

uses API Gateway REST API but invokes the Python Lambda normally.

The application uses:

app = APIGatewayRestResolver()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It's more accurately:

                       Buffered       Streaming

HTTP API v2               ✓               -

REST API v1               ✓               ✓

Function URL              ✓               ✓
Enter fullscreen mode Exit fullscreen mode

Paths and multiple MCP servers

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
Enter fullscreen mode Exit fullscreen mode

The Python application registers the same path:

app.include_mcp(
    mcp,
    path="/orders/mcp",
)
Enter fullscreen mode Exit fullscreen mode

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.

Authentication remains infrastructure

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
Enter fullscreen mode Exit fullscreen mode

This keeps authentication independent from the MCP programming model.

Streaming should be earned

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

without redesigning tools, resources, prompts, middleware, or application services.

A note about cancellation

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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.

One programming model, different infrastructure

The final architecture looks like this:

                         MCPServer
                            │
             ┌──────────────┴──────────────┐
             │                             │
             ▼                             ▼
      Buffered execution             Streamable execution
             │                             │
      regular Lambda                  HTTP process
             │                             │
             ▼                             ▼
   HTTP API / REST / URL            Lambda Web Adapter
                                           │
                                           ▼
                                    Lambda streaming
Enter fullscreen mode Exit fullscreen mode

The important part is what doesn't change:

tools
resources
prompts
dependency injection
middleware
application services
Enter fullscreen mode Exit fullscreen mode

Streaming is an infrastructure capability, not a new application architecture.

Open source

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.

Top comments (0)