Building Lightweight and Streamable MCP Servers on AWS Lambda with Python A developer has released two open-source projects that enable building lightweight and streamable MCP servers on AWS Lambda with Python. The projects, modmex-lambda and an unnamed companion, allow applications to start with a buffered MCP server and opt into real Lambda response streaming when needed, without requiring containers or a full web framework. 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: python 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. php @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: python @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: python @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: python @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: 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.