🍣

AWS Lambda に Statefull Streamable Http Transport Mcp をデプロイする実験

に公開

はじめに

MCP Serverをリモート環境で動作させる際のプロトコルは2025年8月現在、Streamable Http Transport が SSE より推奨されているようです。リモートで MCP Server を動作させるのだから可能ならサーバーレス環境が良いですね。というわけで AWS Lambda 上にデプロイをしてみました。

Streamable Http Transport は Statefull と Stateless が存在します。今回、実験のために Playwright MCP を動作させてみましたが、正しい動作のためには Statefull が要求されました。

Lambda と Statefull

AWS Lambda は本来ステートレスなサーバーレス環境です。そのため利用者はパフォーマンス以外の観点でメモリ上に展開されたキャッシュデータの参照を前提にはできません。したがって、本来なら適切に Streamable Http Transport のセッション情報を保存、復元が必要です。

今回、実験レベルの実装ということでメモリ上にセッション情報をキャッシュしました。本番で利用するかどうかは検証と検討が必要です。

実装

実行環境はLambda Web Adaptgerを利用したDeno + Typescriptです。

Playwright MCP

Playwright MCP は MCP SDK の Server を生成できます。

mcp.ts
import type { Server } from 'npm:@modelcontextprotocol/sdk@1.17.4/server/index.js';
import { createConnection } from 'npm:@playwright/mcp';

export async function createMCPServer(): Promise<Server> {
  return await createConnection({
    browser: {
      browserName: 'chromium',
      isolated: true, // メモリ内でプロファイルを管理
      launchOptions: {
        headless: true,
        timeout: 30000, // 30秒のタイムアウト
        args: [
          '--no-sandbox',
          '--disable-setuid-sandbox',
          '--disable-dev-shm-usage',
          '--disable-gpu',
          '--single-process', // Lambda最適化
          '--disable-features=IsolateOrigins,site-per-process',
          '--disable-web-security',
          '--disable-blink-features=AutomationControlled',
          '--disable-accelerated-2d-canvas',
          '--no-first-run',
          '--no-zygote',
          '--use-gl=swiftshader',
          '--window-size=1920,1080',
          // メモリ最適化
          '--disable-dev-tools',
          '--disable-extensions',
          '--disable-component-extensions-with-background-pages',
        ],
      },
      // contextOptionsは初期ストレージ状態を設定する際に使用
      contextOptions: {},
    },
    capabilities: ['core', 'core-tabs'], // 基本機能のみ有効化
  });
}

Streamable Http Transport

メモリ上にセッションIDをキーにTransportのインスタンスをキャッシュしておきます。この仕組みによって同じセッションであれば同じブラウザが利用可能です。

main.ts
import { StreamableHTTPServerTransport } from 'npm:@modelcontextprotocol/sdk/server/streamableHttp.js';
import cors from 'npm:cors';
import express, { type Request, type Response } from 'npm:express';
import { createMCPServer } from './mcp.ts';

const PORT = parseInt(Deno.env.get('PORT') || '8000', 10);

const app = express();

app.use(
  cors({
    origin: true,
    credentials: true,
  })
);

app.use(express.json());

// Transportはセッションごとに保持する
const transports = new Map<string, StreamableHTTPServerTransport>();

// Streamable HTTPのエンドポイント
app.post('/mcp', async (req: Request, res: Response) => {
  const sessionId = req.headers['mcp-session-id'] as string;
  let transport: StreamableHTTPServerTransport;
  if (sessionId && transports.has(sessionId)) {
    // セッションが存在する場合はそれを使用する
    console.log('Session found:', sessionId);
    transport = transports.get(sessionId) as StreamableHTTPServerTransport;
  } else {
    // セッションが存在しない場合は新しいセッションを作成する
    const server = await createMCPServer();
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => {
        return crypto.randomUUID();
      },
      onsessioninitialized: sessionId => {
        console.log('Session initialized:', sessionId);
        transports.set(sessionId, transport);
      },
      onsessionclosed: sessionId => {
        console.log('Session closed:', sessionId);
        // セッション終了時にTransportを削除する
        transports.delete(sessionId);
      },
    });
    await server.connect(transport);
  }

  try {
    res.on('close', () => {
      console.log('Request closed');
    });

    await transport.handleRequest(req, res, req.body);
  } catch (error) {
    console.error('Error handling MCP request:', error);
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: '2.0',
        error: {
          code: -32603,
          message: 'Internal server error',
        },
        id: null,
      });
    }
  }
});

app.get('/health', (_req: Request, res: Response) => {
  res.status(200).json({ status: 'healthy' });
});

app.listen(PORT, () => {
  console.log(`MCP Server running on port ${PORT}`);
  console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
});

// Lambda関数URL用のエクスポート
export default app;

実行環境を用意

実行環境のコンテナイメージを作ります。Denoの実行環境をベースにブラウザの実行に必要な依存関係を追加しました。

Dockerfile
# Lambda Web Adapter用のマルチステージビルド
FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS aws-lambda-adapter
FROM denoland/deno:bin-2.4.5 AS deno_bin
FROM debian:bookworm-20250811-slim AS deno_runtime

# Lambda Web Adapterのコピー
COPY --from=aws-lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter

# Denoバイナリのコピー
COPY --from=deno_bin /deno /usr/local/bin/deno

# Google Chromeをインストール
RUN apt-get update && apt-get install -y \
    wget \
    gnupg \
    ca-certificates \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
    && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
    && apt-get update \
    && apt-get install -y google-chrome-stable \
    && rm -rf /var/lib/apt/lists/*

ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1

# 環境変数設定
ENV PORT=8000
ENV DENO_DIR=/var/deno_dir
ENV AWS_LWA_INVOKE_MODE=RESPONSE_STREAM

# ポート公開
EXPOSE 8000

# Denoキャッシュディレクトリの作成
RUN mkdir -p /var/deno_dir

# 作業ディレクトリの設定
WORKDIR /var/task

# アプリケーションコードのコピー
COPY lambda/main.ts .
COPY lambda/mcp.ts .

# 依存関係のキャッシュ(コールドスタート時間短縮)
RUN timeout 10s deno run -A main.ts || [ $? -eq 124 ] || exit 1

# 実行コマンド
CMD ["deno", "run", "-A", "main.ts"]

デプロイ

CDK を利用してデプロイします。ブラウザを起動するのでメモリが必要です。また、LambdaのURL呼び出しを利用し、Stream Httpに対応させます。

stack.ts
import * as path from 'node:path';
import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib';
import * as assets from 'aws-cdk-lib/aws-ecr-assets';
import { ManagedPolicy, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import {
  Code,
  FunctionUrlAuthType,
  Handler,
  HttpMethod,
  InvokeMode,
  Function as LambdaFunction,
  Runtime,
} from 'aws-cdk-lib/aws-lambda';
import type { Construct } from 'constructs';

export class PlaywrightOnLambdaStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const lambdaRole = new Role(this, 'LambdaExecutionRole', {
      assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
      ],
    });
    // Lambda関数の作成(Asset Imageを使用してデプロイ時に自動ビルド)
    const lambdaFunction = new LambdaFunction(this, 'DenoLambdaFunction', {
      code: Code.fromAssetImage(path.join(__dirname, '..'), {
        // ビルドはプロジェクトルートで行う
        file: path.join('lambda', 'Dockerfile'),
        // ビルド時の引数(必要に応じて)
        buildArgs: {
          DENO_VERSION: '1.47.0',
          LAMBDA_ADAPTER_VERSION: '0.9.1',
        },
        // プラットフォーム指定(Apple Silicon Mac対応)
        platform: assets.Platform.LINUX_AMD64,
      }),
      handler: Handler.FROM_IMAGE,
      runtime: Runtime.FROM_IMAGE,
      role: lambdaRole,
      timeout: Duration.minutes(15), // ブラウザを使うのでタイムアウトを長く
      memorySize: 2048, // ブラウザを使うのでメモリサイズを増やす
      environment: {
        LOG_LEVEL: 'INFO',
        AWS_LWA_INVOKE_MODE: 'response_stream', // ストリーミングレスポンスを有効化
        AWS_LWA_PORT: '8000',
      },
    });
    const functionUrl = lambdaFunction.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE,
      cors: {
        allowedOrigins: ['*'],
        allowedMethods: [HttpMethod.ALL],
        allowedHeaders: ['*'],
        maxAge: Duration.days(1),
      },
      invokeMode: InvokeMode.RESPONSE_STREAM, // ストリーミングレスポンス
    });
    new CfnOutput(this, 'FunctionUrl', {
      value: functionUrl.url,
      description: 'Function URL',
    });
    new CfnOutput(this, 'FunctionName', {
      value: lambdaFunction.functionName,
      description: 'Function Name',
    });
  }
}

実行

これをデプロイしhttps://<lambda URL>/mcpをエンドポイントに指定してMCPを構成するとLambda上でMCPが動作し、ブラウザが操作されて結果を得られます。

課題

Lambdaは実行時のコンテナをある程度保持し、メモリの再利用が可能です。一方、どのくらい保持されるのか?は文章化されていません。したがってセッション中にメモリが破棄されてセッションの復元に失敗するケースがります。Playwright MCPの場合ブラウザを操作する特性上、ブラウザの状態を厳密に管理します。そのため、セッションの復元に失敗するとすぐにブラウザが操作不能となり、AIツールによるブラウザ操作が全て失敗となります。

Streamable Http Transportのセッション情報をDynamoDBに保存して復元する仕組みは実現可能で、実際awslabs.mcplambdahandlerというモジュールではその仕組みが実装されています。

https://github.com/awslabs/mcp/tree/68045f5eb3df9b26075557a27922ff89772eb08f/src/mcp-lambda-handler

こういった実装を参考にセッションの保存、復元が可能かどうか調査が必要です。

まとめ

AWS Lambda上でStatefull Streamable Http Transport MCPをデプロイする方法を紹介しました。Playwright MCPをサンプルとしましたが、セッションやツール内の状態復元がやりやすいMCP ServerであればLambda上でも適切に実装可能と思われます。

リポジトリ

https://github.com/numa08/playwright-on-lambda

Discussion