AWS Builder Center
Run a serverless MCP server behind Amazon Bedrock AgentCore Gateway with OAuth, using AWS Lambda Web Functions

Run a serverless MCP server behind Amazon Bedrock AgentCore Gateway with OAuth, using AWS Lambda Web Functions

Deploy serverless MCP servers with OAuth authorization using Lambda Web Functions and AgentCore Gateway. A solution that supports all the MCP features, where you only pay for what you use.

I build things, break things, and try to deeply understand how they work
Remote MCP servers are becoming part of standard infrastructure. Agents and IDEs connect to them over HTTPS, and they need what any other API needs: authentication, least-privilege access to the backend, and a hosting model that scales to zero when nobody is using it. In this post I'll put an MCP server on AWS Lambda Web Functions and expose it through Amazon Bedrock AgentCore Gateway. Clients authenticate to the Gateway with OAuth 2.0, and the Gateway calls the function with IAM (SigV4). Nothing in this setup can be reached anonymously, and no credentials live in the server code.
By the end you will have a working endpoint that any MCP client can call with a bearer token. You'll build it with the AWS CLI, the AgentCore CLI and a short Node.js server based on the official MCP TypeScript SDK.

Why this combination

Lambda Web Functions are a newer Lambda function type built for HTTP workloads. Instead of writing an event handler, you write an ordinary Node.js HTTP server that listens on a port. Lambda runs it, gives it an HTTPS endpoint, scales it with traffic (each execution environment serves many concurrent requests), and scales it to zero when it's idle. That's a natural fit for MCP's Streamable HTTP transport, which is plain HTTP POST of JSON-RPC messages. The endpoint can require IAM authentication, so only principals you allow can invoke it.
AgentCore gateway adds the front door. It validates the caller's OAuth token, then signs its own request to the function with SigV4 using a service role that can invoke exactly one endpoint. The function never sees the caller's token, and the caller never holds AWS credentials.
The gateway offers two ways to attach an MCP server. An MCP server target merges the tools of many servers behind a single  /mcp  endpoint, prefixing each tool with its target's name. An HTTP passthrough target with  protocolType: MCP  forwards requests to the server untranslated, at a path of their own. This post uses the passthrough target. Because the gateway doesn't rewrite the MCP messages, any protocol feature your server implements reaches the client unchanged, which matters once you go beyond basic tools (more on that at the end).
Here is the request path:
Plain text
MCP client ──(OAuth bearer token)──► AgentCore Gateway ──(SigV4, gateway role)──► Lambda Web Function
▲ validates JWT IamAuth endpoint
└── Amazon Cognito (client credentials grant)

Why not using AgentCore runtime to host the MCP server? The latest MCP specifications (  2026-07-28  ) made the protocol stateless and removed the  MCP-Session-Id  header. If you host an MCP server on AgentCore runtime and you use the latest protocol version, AgentCore spins up a microVM for each tool call. This means incurring in the microVM cold start at every call (~2sec) and also quickly consuming the concurrent session quotas.

Prerequisites

You need an AWS account, a recent AWS CLI v2 that includes the  aws lambda-web  commands (run  aws lambda-web help  to check, and  aws update  or reinstall if it's missing), Node.js 24 with npm, the AgentCore CLI ( npm install -g @aws/agentcore ; this post uses version 1.0.1), and  curl  and  jq . The commands below use  us-east-1 . Set the variables once and keep the same shell open for the whole walkthrough:
Bash
export AWS_REGION=us-east-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export APP=hello-mcp

The MCP server

The server uses the official MCP TypeScript SDK:  McpServer  holds the tools, and  StreamableHTTPServerTransport  speaks the Streamable HTTP transport. It runs in stateless mode, which suits Lambda: a Web Function environment handles many concurrent requests on one event loop, and Lambda can route consecutive requests to different environments, so the server builds a fresh  McpServer  and transport for every request and keeps nothing between them. There are two more small adjustments. The SDK returns  406  when the  Accept  header doesn't list both JSON and SSE, and some clients send neither, so the server normalizes the header.  enableJsonResponse  makes simple request/response calls return plain JSON instead of an SSE stream. The only Lambda-specific line is the first one: a Web Function tells your code where to listen through the  AWS_LAMBDA_HTTP_ENDPOINT  environment variable, and the server falls back to loopback when it runs on your laptop.
Create a directory  mcp-server  with two files. First,  mcp-server/package.json :
JSON
{
"name": "hello-mcp",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.32.2",
"zod": "^4.6.7"
}
}
Then  mcp-server/index.js :
JavaScript
// Minimal stateless MCP server for AWS Lambda Web Functions, built on the official MCP TypeScript SDK.
import http from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

// Lambda tells the server where to listen; fall back to loopback for local runs.
const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? "127.0.0.1:3000").split(":");

function buildServer() {
const server = new McpServer({ name: "hello-mcp", version: "1.0.0" });
server.registerTool(
"greet",
{ description: "Returns a greeting for the given name.", inputSchema: { name: z.string() } },
async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] }),
);
return server;
}

http.createServer(async (req, res) => {
if (!req.url?.startsWith("/mcp")) { res.writeHead(404).end(); return; }

// The SDK answers 406 unless Accept lists both JSON and SSE; some clients send neither.
const accept = req.headers["accept"] ?? "";
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
req.headers["accept"] = "application/json, text/event-stream";
}

// Stateless: a fresh server and transport per request. One Web Function environment serves many
// concurrent requests on one event loop, and a shared stateless transport would mix up their ids.
const server = buildServer();
// enableJsonResponse returns plain JSON for request/response calls instead of an SSE stream.
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res);
}).listen(Number(port), host);
Install the dependencies with  npm install --prefix mcp-server --omit=dev . You can try the server locally with  node mcp-server/index.js  and a  curl  POST to  http://127.0.0.1:3000/mcp .

Deploy the Web Function

A Web Function contains immutable revisions, each a snapshot of code and configuration, and endpoints that route HTTPS traffic to them. The  aws lambda-web deploy  command does all the setup in one step. It zips the directory (including  node_modules ), uploads the package to a versioned, private S3 bucket that it manages for your account and Region, creates an execution role with CloudWatch Logs access, creates the function and its first revision, and waits until the endpoint is active. Two options matter here.  --auth-type IamAuth  makes Lambda reject any request that isn't SigV4-signed by a principal allowed to call  lambda:InvokeWebFunctionEndpoint  (without it, the endpoint would be public).  --create  confirms creation without an interactive prompt.
Bash
aws lambda-web deploy --name $APP --code ./mcp-server --endpoint-name mcp --auth-type IamAuth --create

export WEB_FUNCTION_DOMAIN=$(aws lambda-web get-web-function-endpoint --function-name $APP --endpoint-name mcp \
--query domainName --output text)
export ENDPOINT_ARN=$(aws lambda-web get-web-function-endpoint --function-name $APP --endpoint-name mcp \
--query endpointArn --output text)
The command prints the endpoint URL when it finishes. It also warns that it skipped some hidden files inside  node_modules ; those are tooling files the server doesn't need at runtime.
The function now has an HTTPS endpoint, but you can't call it with a plain request:  curl -X POST https://$WEB_FUNCTION_DOMAIN/mcp  returns  403 . That's what you want, because from here on only the Gateway will be allowed in.

Set up OAuth with Amazon Cognito

The Gateway needs an OAuth 2.0 authorization server that issues JWTs it can validate through an OpenID Connect discovery URL. Any compliant identity provider works. This walkthrough uses an Amazon Cognito user pool with the client credentials grant, which is the usual choice when the MCP client is an agent or a service, not a person. Self sign-up is disabled and the pool has no users: the only way to get a token is to hold the app client's secret. The resource server defines a scope,  hello-mcp/invoke , that the Gateway will require.
Bash
export POOL_ID=$(aws cognito-idp create-user-pool --pool-name $APP-users \
--admin-create-user-config AllowAdminCreateUserOnly=true --query UserPool.Id --output text)
aws cognito-idp create-user-pool-domain --domain $APP-$ACCOUNT_ID --user-pool-id $POOL_ID
aws cognito-idp create-resource-server --user-pool-id $POOL_ID --identifier $APP --name $APP \
--scopes ScopeName=invoke,ScopeDescription="Invoke the MCP server"
export CLIENT_ID=$(aws cognito-idp create-user-pool-client --user-pool-id $POOL_ID --client-name $APP-agent \
--generate-secret --allowed-o-auth-flows client_credentials --allowed-o-auth-scopes $APP/invoke \
--allowed-o-auth-flows-user-pool-client --query UserPoolClient.ClientId --output text)

Create the gateway and the passthrough target

The Gateway signs its calls to the function with a service role. The trust policy lets AgentCore assume the role only on behalf of your account, and the permissions policy allows exactly one action on exactly one endpoint:
Bash
aws iam create-role --role-name $APP-gateway --assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "bedrock-agentcore.amazonaws.com"},
"Action": "sts:AssumeRole",
"Condition": {"StringEquals": {"aws:SourceAccount": "'$ACCOUNT_ID'"}}
}]
}'
aws iam put-role-policy --role-name $APP-gateway --policy-name invoke-web-function --policy-document '{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "lambda:InvokeWebFunctionEndpoint", "Resource": "'$ENDPOINT_ARN'"}]
}'
sleep 15
The gateway itself is managed with the AgentCore CLI, which keeps it as code in a project and deploys it through AWS CloudFormation. Start with an empty project, which has no agent Runtime, only the resources you add:
Bash
agentcore create --name hellomcpgw --template empty --skip-git
cd hellomcpgw
Add the gateway. The inbound authorizer is  CUSTOM_JWT , pointed at the Cognito discovery URL, so only tokens issued to your app client with the  hello-mcp/invoke  scope are accepted.  --role-arn  passes in the role you just created. Note what's missing: there is no  --protocol-type . HTTP passthrough targets need a gateway that isn't restricted to MCP targets, because the gateway won't interpret the traffic.
Bash
agentcore add gateway --name ${APP}-gw --role-arn arn:aws:iam::${ACCOUNT_ID}:role/${APP}-gateway \
--authorizer-type CUSTOM_JWT \
--authorizer-configuration '{"customJwtAuthorizer": {
"discoveryUrl": "https://cognito-idp.'$AWS_REGION'.amazonaws.com/'$POOL_ID'/.well-known/openid-configuration",
"allowedClients": ["'$CLIENT_ID'"],
"allowedScopes": ["'$APP'/invoke"]}}'
Then attach the Web Function as an HTTP passthrough target. The  endpoint  is the function's base URL. The Gateway forwards  https://<gateway>/<target-name>/<path>  to  <endpoint>/<path> , so a client calling  /hello/mcp  on the Gateway reaches  /mcp  on the function.  outboundAuth  tells the Gateway to sign outbound requests with its own role, using SigV4 for the  lambda  service.
Bash
agentcore add gateway-target --gateway ${APP}-gw --target-configuration '{
"name": "hello",
"targetType": "passthrough",
"passthrough": {"endpoint": "https://'$WEB_FUNCTION_DOMAIN'", "protocolType": "MCP"},
"outboundAuth": {"type": "GATEWAY_IAM_ROLE", "service": "lambda", "region": "'$AWS_REGION'"}
}'
Both commands only edit  agentcore/agentcore.json , which you can review before anything is created. Deploy it, then look up the gateway ID. The CLI prefixes the gateway name with the project and deployment target names.
Bash
agentcore deploy --region $AWS_REGION

export GATEWAY_ID=$(agentcore status --json --region $AWS_REGION \
| jq -r '.resources[] | select(.resourceType == "gateway") | .arn | split("/")[1]')
export MCP_URL=https://${GATEWAY_ID}.gateway.bedrock-agentcore.${AWS_REGION}.amazonaws.com/hello/mcp
On the first deploy, the CLI bootstraps AWS CDK in the account and Region if needed. It also enables CloudWatch Transaction Search for agent tracing, which you can turn off beforehand with  agentcore config transactionSearch false .

Call it

To get a token, the client presents its ID and secret to Cognito's token endpoint. In a real deployment the agent would keep the secret in AWS Secrets Manager or receive it from AgentCore Identity. For this test, read it from Cognito:
Bash
export CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client --user-pool-id $POOL_ID --client-id $CLIENT_ID \
--query UserPoolClient.ClientSecret --output text)
export TOKEN=$(curl -s -u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=client_credentials&scope=$APP/invoke" \
https://$APP-$ACCOUNT_ID.auth.$AWS_REGION.amazoncognito.com/oauth2/token | jq -r .access_token)

curl -s $MCP_URL -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "greet", "arguments": {"name": "Builder"}}}'
The response comes back from the function, through the Gateway:
Bash
{"result":{"content":[{"type":"text","text":"Hello, Builder!"}]},"jsonrpc":"2.0","id":1}
Without the  Authorization  header the Gateway answers  401 , and a request sent straight to the function's domain still gets  403 . To see the same thing from a standard MCP client, run the MCP Inspector, which performs the full  initialize  handshake before calling the tool:
Bash
npx @modelcontextprotocol/inspector --cli $MCP_URL --transport http \
--header "Authorization: Bearer $TOKEN" --method tools/call --tool-name greet --tool-arg name=Inspector
The AgentCore CLI can make the same call by gateway name, which is handy while you develop. Run it from the project directory:
Bash
agentcore invoke --region $AWS_REGION --gateway ${APP}-gw --path /hello/mcp --bearer-token "$TOKEN" \
--payload '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "greet", "arguments": {"name": "AgentCore CLI"}}}'
Any MCP client that can send a bearer token connects the same way: point it at  $MCP_URL  and configure the  Authorization  header. (Cognito access tokens from this client expire after an hour by default.)
When you change the server, run  aws lambda-web deploy --name $APP --code ./mcp-server  again from the directory that contains  mcp-server . It uploads the new code and creates a new revision. The endpoint created by  deploy  routes to the latest active revision, so the URL, the gateway target and the IAM policy all stay the same.

Going further: any MCP feature, end to end

The server above registers a single tool, but the setup around it doesn't limit what you can build. You can add resources, prompts and more tools to the same  McpServer , and the passthrough target forwards your server's MCP messages unchanged, so whatever the server implements reaches the client unchanged. That includes progress notifications, elicitation, structured tool output, resources and prompts, streamed SSE responses (Web Functions stream responses without extra configuration), and protocol revisions that don't exist yet.
I tested the most demanding case with a hand-written server, MCP Tasks: long-running tool calls that return a task handle, which the client then polls, updates and cancels. I implemented both the core Tasks utility from protocol revision 2025-11-25 and the  io.modelcontextprotocol/tasks  extension from 2026-07-28. Every check passed through the passthrough target: task creation, polling with  tasks/get , blocking  tasks/result ,  tasks/update  for input requests, cancellation, and resuming from a fresh client. The same server attached as an MCP server target lost the task handles in transit. If your server relies on protocol features beyond plain tool calls, use the passthrough target.
Two design points come with that freedom. First, a Web Function endpoint spreads requests across execution environments that Lambda can add or remove at any time, and the 2026-07-28 protocol has no sessions to pin a client to one of them. Anything that has to outlive a single request, such as task state or an in-progress elicitation, belongs in an external store like Amazon DynamoDB, not in process memory. Second, the Gateway authenticates the caller and then signs its own request, so every request reaches your server as the gateway role. If your server must tell users apart, for example to bind a task to the user who created it as the Tasks specification requires, pass the user's identity through explicitly, for instance with a gateway interceptor that copies the token's  sub  claim into a header.

Clean up

Delete the resources in reverse order. Removing everything from the AgentCore project and deploying again deletes the gateway and its target. Run this from the  hellomcpgw  directory, then go back up one level:
Bash
agentcore remove all --yes
agentcore deploy --region $AWS_REGION --yes
cd ..
Then remove the rest with the AWS CLI:
Bash
aws iam delete-role-policy --role-name $APP-gateway --policy-name invoke-web-function
aws iam delete-role --role-name $APP-gateway
aws cognito-idp delete-user-pool-domain --domain $APP-$ACCOUNT_ID --user-pool-id $POOL_ID
aws cognito-idp delete-user-pool --user-pool-id $POOL_ID
aws lambda-web delete-web-function --function-name $APP
Deleting the Web Function also deletes its endpoints and revisions, but not what  deploy  created around it: the execution role  awscli-lambdaweb-hello-mcp , the deployment packages under  lambda-web/hello-mcp/  in the managed bucket  awscli-lambdaweb-<account-id>-<region>-an , and the CloudWatch log groups  /aws/lambda/web/hello-mcp* . Remove them once the function is gone (never before: a revision whose code object version disappears moves permanently to  Failed ). Other Web Functions in the same account and Region can share the bucket, so delete only this function's packages:
Bash
aws iam delete-role-policy --role-name awscli-lambdaweb-$APP --policy-name awscli-lambdaweb-logs
aws iam delete-role --role-name awscli-lambdaweb-$APP
export DEPLOY_BUCKET=awscli-lambdaweb-$ACCOUNT_ID-$AWS_REGION-an
aws s3api delete-objects --bucket $DEPLOY_BUCKET --delete "$(aws s3api list-object-versions \
--bucket $DEPLOY_BUCKET --prefix lambda-web/$APP/ \
--query '{Objects: Versions[].{Key: Key, VersionId: VersionId}}' --output json)"

Wrapping up

With a short SDK-based server, a few AWS CLI calls and a small AgentCore project, you get an MCP server that scales to zero, is reachable only through an OAuth-protected front door, and reaches its backend with a narrowly scoped IAM role. Lambda Web Functions let you write the MCP server as the HTTP service it is. The AgentCore Gateway HTTP passthrough target adds authentication without getting in the way of the protocol, which is what keeps every MCP feature available as the protocol evolves.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article