Skip to content

Commit 3d015c2

Browse files
authored
first commit
1 parent 71a37d5 commit 3d015c2

File tree

10 files changed

+346
-1
lines changed

10 files changed

+346
-1
lines changed

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,50 @@
1-
# simple-mcp-server-on-lambda
1+
# Simple MCP Server on Lambda
2+
3+
A simple MCP Server running natively on AWS Lambda and Amazon API Gateway without any extra bridging components or custom transports. This is now possible thanks to the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport introduced in v2025-03-26.
4+
5+
Architecture is as simple as it gets:
6+
![](architecture.png)
7+
8+
## Prereqs
9+
10+
* AWS CLI
11+
* Terraform
12+
13+
## Instructions
14+
15+
Install dependencies:
16+
```bash
17+
cd src
18+
npm install
19+
cd ..
20+
```
21+
22+
Bootstrap server and set env var with MCP Server endpoint:
23+
```bash
24+
cd terraform
25+
terraform init
26+
terraform plan
27+
terraform apply
28+
export SIMPLE_MCP_SERVER_ENDPOINT=$(terraform output --raw endpoint_url)
29+
cd ..
30+
```
31+
32+
> Note: It might take a few seconds for API Gateway endpoint to become operational.
33+
34+
35+
Run client:
36+
```bash
37+
node src/client.js
38+
```
39+
40+
Observe the response:
41+
```bash
42+
> node client.js
43+
> listTools response: { tools: [ { name: 'ping', inputSchema: [Object] } ] }
44+
> callTool:ping response: { content: [ { type: 'text', text: 'pong' } ] }
45+
```
46+
47+
## Learn about mcp
48+
[Intro](https://modelcontextprotocol.io/introduction)
49+
50+
[Protocol specification](https://modelcontextprotocol.io/specification/2025-03-26)

src/client.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3+
4+
const ENDPOINT_URL = process.env.SIMPLE_MCP_SERVER_ENDPOINT;
5+
console.log(`Connecting ENDPOINT_URL=${ENDPOINT_URL}`);
6+
7+
const transport = new StreamableHTTPClientTransport(new URL(ENDPOINT_URL));
8+
9+
const client = new Client({
10+
name: "node-client",
11+
version: "0.0.1"
12+
})
13+
14+
await client.connect(transport);
15+
const tools = await client.listTools();
16+
console.log(`listTools response: `, tools);
17+
18+
const result = await client.callTool({
19+
name: "ping"
20+
});
21+
console.log(`callTool:ping response: `, result);
22+
23+
await client.close();

src/logging.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import log4js from 'log4js';
2+
import { unlinkSync } from 'fs';
3+
4+
const LOG_FILE = './server.log';
5+
6+
// try { unlinkSync(LOG_FILE); } catch (e) { };
7+
8+
const layout = {
9+
type: 'pattern',
10+
// pattern: '%[%d{hh:mm:ss.SSS} %p [%f{1}:%l:%M] %m%]'
11+
pattern: '%[%p [%f{1}:%l:%M] %m%]'
12+
}
13+
14+
log4js.configure({
15+
appenders: {
16+
stdout: {
17+
type: 'stdout',
18+
enableCallStack: true,
19+
layout
20+
},
21+
file: {
22+
type: 'file',
23+
filename: LOG_FILE,
24+
enableCallStack: true,
25+
layout
26+
}
27+
},
28+
categories: {
29+
default: {
30+
appenders: ['stdout'],
31+
// appenders: ['file'],
32+
level: 'debug',
33+
enableCallStack: true
34+
}
35+
}
36+
});

src/mcpServer.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { z } from "zod";
3+
4+
const mcpServer = new McpServer({
5+
name: "simple-mcp-server-on-lambda",
6+
version: "0.0.1"
7+
}, {
8+
capabilities: {
9+
tools: {}
10+
}
11+
});
12+
13+
mcpServer.tool("ping", ()=>{
14+
return {
15+
content: [
16+
{
17+
type: "text",
18+
text: "pong"
19+
}
20+
]
21+
}
22+
});
23+
24+
export default mcpServer;

src/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"type": "module",
3+
"author": "Anton Aleksandrov",
4+
"license": "Apache-2.0",
5+
"description": "A simple MCP Server running on AWS Lambda",
6+
"dependencies": {
7+
"@codegenie/serverless-express": "^4.16.0",
8+
"@modelcontextprotocol/sdk": "^1.10.1",
9+
"express": "^5.1.0",
10+
"log4js": "^6.9.1"
11+
}
12+
}
13+

src/server.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import './logging.js';
2+
import log4js from 'log4js';
3+
import express from "express";
4+
import { randomUUID } from "node:crypto";
5+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6+
import { InitializeRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7+
import mcpServer from './mcpServer.js';
8+
9+
const l = log4js.getLogger();
10+
11+
const app = express();
12+
app.use(express.json());
13+
app.use((req, res, next) => {
14+
l.debug(`> ${req.method} ${req.originalUrl}`);
15+
l.debug(req.body);
16+
res.set('Access-Control-Allow-Origin', '*');
17+
res.set('Access-Control-Allow-Headers', '*');
18+
return next();
19+
});
20+
21+
// Map to store transports by session ID
22+
const transports = {};
23+
24+
// Handle POST requests for client-to-server communication
25+
app.post('/mcp', async (req, res) => {
26+
const sessionId = req.headers['mcp-session-id'];
27+
let transport;
28+
29+
if (sessionId && transports[sessionId]) {
30+
l.debug(`found existing transport for sessionId=${sessionId}`);
31+
transport = transports[sessionId];
32+
} else if (!sessionId && isInitializeRequest(req.body)) {
33+
l.debug(`creating new transport`);
34+
transport = new StreamableHTTPServerTransport({
35+
//sessionIdGenerator: () => undefined,
36+
// ^ this should be used for fully sessionless connections,
37+
// but there's currently a bug preventing it. Waiting for next
38+
// SDK release to be fixed. In the meanwhile, let's use sessions.
39+
40+
sessionIdGenerator: () => randomUUID(),
41+
enableJsonResponse: true,
42+
onsessioninitialized: (sessionId) => {
43+
l.debug(`transport.onsessioninitialized sessionId=${sessionId}`);
44+
transports[sessionId] = transport;
45+
}
46+
});
47+
48+
await mcpServer.connect(transport);
49+
} else {
50+
l.debug(`Invalid request, no sessionId`);
51+
res.status(400).json({
52+
jsonrpc: '2.0',
53+
error: {
54+
code: -32000,
55+
message: 'Bad Request: No valid session ID provided',
56+
},
57+
id: null,
58+
});
59+
return;
60+
}
61+
62+
await transport.handleRequest(req, res, req.body);
63+
});
64+
65+
function isInitializeRequest(body) {
66+
const isInitial = (data) => {
67+
const result = InitializeRequestSchema.safeParse(data)
68+
return result.success
69+
}
70+
if (Array.isArray(body)) {
71+
return body.some(request => isInitial(request))
72+
}
73+
return isInitial(body)
74+
}
75+
76+
// Handle GET requests for server-to-client notifications via SSE
77+
app.get('/mcp', (req, res) => {
78+
res.status(405).set('Allow', 'POST').send('Method Not Allowed');
79+
});
80+
81+
// Handle DELETE requests for session termination
82+
app.delete('/mcp', (req, res) => {
83+
res.status(405).set('Allow', 'POST').send('Method Not Allowed');
84+
});
85+
86+
const port = 3000;
87+
app.listen(port, () => {
88+
l.debug(`Listening on http://localhost:${port}`);
89+
});
90+
91+
import serverlessExpress from '@codegenie/serverless-express';
92+
export const handler = serverlessExpress({ app });

terraform/apigateway.tf

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
resource "aws_api_gateway_rest_api" "api" {
2+
name = "simple-mcp-server"
3+
}
4+
5+
resource "aws_api_gateway_resource" "mcp" {
6+
rest_api_id = aws_api_gateway_rest_api.api.id
7+
parent_id = aws_api_gateway_rest_api.api.root_resource_id
8+
path_part = "mcp"
9+
}
10+
11+
resource "aws_api_gateway_method" "any" {
12+
rest_api_id = aws_api_gateway_rest_api.api.id
13+
resource_id = aws_api_gateway_resource.mcp.id
14+
authorization = "NONE"
15+
http_method = "ANY"
16+
}
17+
18+
resource "aws_api_gateway_integration" "lambda" {
19+
rest_api_id = aws_api_gateway_rest_api.api.id
20+
resource_id = aws_api_gateway_resource.mcp.id
21+
http_method = aws_api_gateway_method.any.http_method
22+
integration_http_method = "POST"
23+
type = "AWS_PROXY"
24+
uri = aws_lambda_function.simple_mcp_server.invoke_arn
25+
}
26+
27+
resource "aws_lambda_permission" "apigateway" {
28+
statement_id = "AllowExecutionFromAPIGateway"
29+
action = "lambda:InvokeFunction"
30+
function_name = aws_lambda_function.simple_mcp_server.function_name
31+
principal = "apigateway.amazonaws.com"
32+
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*/*"
33+
}
34+
35+
resource "aws_api_gateway_deployment" "api" {
36+
rest_api_id = aws_api_gateway_rest_api.api.id
37+
depends_on = [aws_api_gateway_method.any, aws_api_gateway_integration.lambda]
38+
lifecycle {
39+
create_before_destroy = true
40+
}
41+
triggers = {
42+
redeployment = timestamp() //always
43+
}
44+
}
45+
46+
resource "aws_api_gateway_stage" "dev" {
47+
rest_api_id = aws_api_gateway_rest_api.api.id
48+
deployment_id = aws_api_gateway_deployment.api.id
49+
stage_name = "dev"
50+
}

terraform/lambda.tf

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
resource "aws_iam_role" "simple_mcp_server" {
2+
name = "simple-mcp-server"
3+
assume_role_policy = jsonencode({
4+
Version = "2012-10-17"
5+
Statement = [
6+
{
7+
Action = "sts:AssumeRole"
8+
Effect = "Allow"
9+
Principal = {
10+
Service = "lambda.amazonaws.com"
11+
}
12+
}
13+
]
14+
})
15+
}
16+
17+
resource "aws_iam_role_policy_attachment" "simple_mcp_server" {
18+
role = aws_iam_role.simple_mcp_server.name
19+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
20+
}
21+
22+
data "archive_file" "simple_mcp_server" {
23+
type = "zip"
24+
source_dir = "${path.root}/../src"
25+
output_path = "${path.root}/tmp/function.zip"
26+
}
27+
28+
resource "aws_lambda_function" "simple_mcp_server" {
29+
function_name = "simple_mcp_server"
30+
filename = data.archive_file.simple_mcp_server.output_path
31+
source_code_hash = data.archive_file.simple_mcp_server.output_base64sha256
32+
role = aws_iam_role.simple_mcp_server.arn
33+
handler = "server.handler"
34+
runtime = "nodejs22.x"
35+
memory_size = 512
36+
timeout = 30
37+
}
38+

terraform/outputs.tf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
output "endpoint_url" {
2+
value = "${aws_api_gateway_stage.dev.invoke_url}/${aws_api_gateway_resource.mcp.path_part}"
3+
}

terraform/provider.tf

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
terraform {
2+
required_providers {
3+
aws = {
4+
source = "hashicorp/aws"
5+
version = "~> 5.0"
6+
}
7+
}
8+
}
9+
10+
provider "aws" {
11+
region = "us-east-1"
12+
default_tags {
13+
tags = {
14+
Project = "simple-mcp-server-on-lambda"
15+
}
16+
}
17+
}

0 commit comments

Comments
 (0)