AI Agents による定型運用の自動化:MCP(Model Context Protocol)を活用したインフラ操作

|
AI MCP Automation LLM Claude Infrastructure エージェント

Model Context Protocol(MCP)を使ってAIエージェントにインフラ操作を委譲する実践。定型的なAWS/Cloudflare操作をAI Agentsで自動化し、運用工数を削減する設計パターン。

はじめに:「手順が決まっている作業」を人間が毎回やる問題

インフラ運用の現場には、「手順は決まっているが、毎回CLIを叩くのが面倒」なタスクが積み重なっている。

定型運用タスクの例:

  障害調査(30分かかる作業):
  1. CloudWatch でエラーログを検索
  2. ECS サービスのイベント履歴を確認
  3. 直近のデプロイ時刻と照合
  4. Lambda の実行エラー数を集計
  5. RDS のコネクション数を確認
  → これらを順番にCLIで実行して結果をまとめる

  定期クリーンアップ(月1回):
  1. 古い ECR イメージを削除(最新5件を残す)
  2. 停止中の EC2 インスタンスを確認してレポート
  3. 未使用の Elastic IP を検出
  4. S3 バケットの不要ファイルを削除
  → 手順書を見ながら順番に実行する

  デプロイ後の確認(毎回):
  1. ECS サービスの desired/running count を確認
  2. CloudWatch アラームの状態を確認
  3. ALB ターゲットグループのヘルスを確認
  4. エラーレートが上昇していないか確認

これらは 全て手順が決まっている。手順が決まっているということは、AIが道具を持っていれば実行できる

MCP(Model Context Protocol)は、AIに「道具(Tool)」を渡す標準的なプロトコルだ。


Part 1:MCP の仕組み ── AIに道具を渡すプロトコル

MCP のアーキテクチャ:

  [Claude / Cursor / AI Agent]
         ↕ MCP プロトコル(JSON-RPC over stdio / HTTP)
  [MCP Server]
    ├── Tool: list_ecs_services()
    ├── Tool: get_cloudwatch_errors()
    ├── Tool: stop_ec2_instance()
    └── Tool: cleanup_ecr_images()
         ↕ AWS SDK / Cloudflare API
  [AWS / Cloudflare]


MCP の役割分担:

  MCP Client(Claude等):
    - 自然言語でユーザーの意図を理解する
    - どのToolを何の引数で呼ぶか判断する
    - 結果を解釈して次のアクションを決める

  MCP Server(自分で実装):
    - Toolの定義(名前・説明・引数スキーマ)を公開する
    - Toolの実行ロジックを実装する
    - AWS SDK / API を叩いて結果を返す

  重要な設計原則:
    - MCP Server は「AIが使える道具箱」であり、制御はAIが行う
    - 破壊的操作のToolは「確認を求める」設計にする
    - Serverのコードで権限の上限を決める(IAMと二重の防御)

Part 2:MCP Server の実装(TypeScript)

プロジェクトのセットアップ

mkdir infra-mcp-server && cd infra-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk @aws-sdk/client-ecs \
  @aws-sdk/client-cloudwatch-logs @aws-sdk/client-ec2 \
  @aws-sdk/client-ecr zod
npm install -D typescript @types/node tsx
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "strict": true
  }
}

Tool の定義と実装

// src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { ECSClient, DescribeServicesCommand, ListServicesCommand, UpdateServiceCommand } from '@aws-sdk/client-ecs';
import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs';
import { ECRClient, ListImagesCommand, BatchDeleteImageCommand, DescribeImagesCommand } from '@aws-sdk/client-ecr';
import { z } from 'zod';

const AWS_REGION = process.env.AWS_REGION ?? 'ap-northeast-1';
const ecs   = new ECSClient({ region: AWS_REGION });
const logs  = new CloudWatchLogsClient({ region: AWS_REGION });
const ecr   = new ECRClient({ region: AWS_REGION });

const server = new Server(
  { name: 'infra-mcp-server', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

// ── Tool の一覧を返す ──────────────────────────────
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'get_ecs_service_status',
      description: 'ECS サービスの running / desired カウントとイベント履歴を取得する。デプロイ後の確認や障害調査に使う。',
      inputSchema: {
        type: 'object',
        properties: {
          cluster: { type: 'string', description: 'ECS クラスター名' },
          service: { type: 'string', description: 'ECS サービス名' },
        },
        required: ['cluster', 'service'],
      },
    },
    {
      name: 'search_cloudwatch_errors',
      description: '指定したロググループから ERROR ログを検索して返す。障害調査の最初のステップ。',
      inputSchema: {
        type: 'object',
        properties: {
          logGroupName: { type: 'string', description: 'CloudWatch ロググループ名' },
          minutesAgo:   { type: 'number', description: '何分前からのログを検索するか(デフォルト: 60)' },
          filterPattern:{ type: 'string', description: 'CloudWatch フィルターパターン(デフォルト: ERROR)' },
        },
        required: ['logGroupName'],
      },
    },
    {
      name: 'cleanup_ecr_images',
      description: 'ECR リポジトリの古いイメージを削除する。最新 N 件を残し、それ以前を削除する。破壊的操作のため dry_run=true で確認してから実行すること。',
      inputSchema: {
        type: 'object',
        properties: {
          repositoryName: { type: 'string', description: 'ECR リポジトリ名' },
          keepCount:      { type: 'number', description: '残すイメージ数(デフォルト: 5)' },
          dry_run:        { type: 'boolean', description: 'true の場合は削除せずに対象リストのみ返す(デフォルト: true)' },
        },
        required: ['repositoryName'],
      },
    },
    {
      name: 'scale_ecs_service',
      description: 'ECS サービスの desired count を変更する。スケールアップ・ダウン・緊急停止(0)に使う。破壊的操作のため必ず確認を求めること。',
      inputSchema: {
        type: 'object',
        properties: {
          cluster:      { type: 'string', description: 'ECS クラスター名' },
          service:      { type: 'string', description: 'ECS サービス名' },
          desiredCount: { type: 'number', description: '変更後の desired count' },
        },
        required: ['cluster', 'service', 'desiredCount'],
      },
    },
  ],
}));

// ── Tool の実行ロジック ───────────────────────────
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  switch (name) {
    // ECS サービスステータス取得
    case 'get_ecs_service_status': {
      const { cluster, service } = z.object({
        cluster: z.string(),
        service: z.string(),
      }).parse(args);

      const res = await ecs.send(new DescribeServicesCommand({
        cluster,
        services: [service],
      }));
      const svc = res.services?.[0];
      if (!svc) return { content: [{ type: 'text', text: `Service ${service} not found` }] };

      const events = (svc.events ?? []).slice(0, 5).map(e =>
        `${e.createdAt?.toISOString()} ${e.message}`
      ).join('\n');

      return {
        content: [{
          type: 'text',
          text: [
            `Service: ${svc.serviceName}`,
            `Status: ${svc.status}`,
            `Desired: ${svc.desiredCount} / Running: ${svc.runningCount} / Pending: ${svc.pendingCount}`,
            `Last deployment: ${svc.deployments?.[0]?.createdAt?.toISOString()}`,
            `Recent events:\n${events}`,
          ].join('\n'),
        }],
      };
    }

    // CloudWatch ログ検索
    case 'search_cloudwatch_errors': {
      const { logGroupName, minutesAgo = 60, filterPattern = 'ERROR' } = z.object({
        logGroupName:  z.string(),
        minutesAgo:    z.number().default(60),
        filterPattern: z.string().default('ERROR'),
      }).parse(args);

      const startTime = Date.now() - minutesAgo * 60 * 1000;
      const res = await logs.send(new FilterLogEventsCommand({
        logGroupName,
        startTime,
        filterPattern,
        limit: 20,
      }));

      const events = (res.events ?? []).map(e =>
        `[${new Date(e.timestamp ?? 0).toISOString()}] ${e.message?.trim()}`
      ).join('\n');

      return {
        content: [{
          type: 'text',
          text: events.length > 0
            ? `Found ${res.events?.length} events:\n\n${events}`
            : `No ${filterPattern} events in the last ${minutesAgo} minutes`,
        }],
      };
    }

    // ECR イメージクリーンアップ
    case 'cleanup_ecr_images': {
      const { repositoryName, keepCount = 5, dry_run = true } = z.object({
        repositoryName: z.string(),
        keepCount:      z.number().default(5),
        dry_run:        z.boolean().default(true),
      }).parse(args);

      const imagesRes = await ecr.send(new DescribeImagesCommand({
        repositoryName,
      }));
      const images = (imagesRes.imageDetails ?? [])
        .sort((a, b) => (b.imagePushedAt?.getTime() ?? 0) - (a.imagePushedAt?.getTime() ?? 0));

      const toDelete = images.slice(keepCount);
      if (toDelete.length === 0) {
        return { content: [{ type: 'text', text: `No images to delete (${images.length} total, keeping ${keepCount})` }] };
      }

      const deleteList = toDelete.map(i =>
        `  ${i.imageDigest?.slice(0, 16)}... pushed: ${i.imagePushedAt?.toISOString()}`
      ).join('\n');

      if (dry_run) {
        return {
          content: [{
            type: 'text',
            text: `[DRY-RUN] Would delete ${toDelete.length} images:\n${deleteList}\n\nRun with dry_run=false to execute.`,
          }],
        };
      }

      await ecr.send(new BatchDeleteImageCommand({
        repositoryName,
        imageIds: toDelete.map(i => ({ imageDigest: i.imageDigest })),
      }));

      return {
        content: [{ type: 'text', text: `Deleted ${toDelete.length} images:\n${deleteList}` }],
      };
    }

    // ECS スケール変更
    case 'scale_ecs_service': {
      const { cluster, service, desiredCount } = z.object({
        cluster:      z.string(),
        service:      z.string(),
        desiredCount: z.number().min(0).max(100),
      }).parse(args);

      await ecs.send(new UpdateServiceCommand({
        cluster,
        service,
        desiredCount,
      }));

      return {
        content: [{
          type: 'text',
          text: `Updated ${service} desired count to ${desiredCount}. Use get_ecs_service_status to monitor rollout.`,
        }],
      };
    }

    default:
      throw new Error(`Unknown tool: ${name}`);
  }
});

// サーバー起動
const transport = new StdioServerTransport();
await server.connect(transport);

Part 3:Claude Desktop での設定と実際の使い方

claude_desktop_config.json の設定

{
  "mcpServers": {
    "infra": {
      "command": "node",
      "args": ["/path/to/infra-mcp-server/dist/server.js"],
      "env": {
        "AWS_REGION": "ap-northeast-1",
        "AWS_PROFILE": "my-aws-profile"
      }
    }
  }
}

実際の会話例

ユーザー:
  本番ECSのapi-serviceが正常か確認して。
  もし問題があれば CloudWatch のログも見てほしい。

Claude:
  [Tool: get_ecs_service_status]
  cluster=production, service=api-service

  Service: api-service
  Status: ACTIVE
  Desired: 2 / Running: 1 / Pending: 1
  Last deployment: 2026-03-01T10:15:00Z
  Recent events:
    10:16:32 service api-service has started 1 tasks
    10:15:01 service api-service deregistered 1 targets

  Running が Desired より少ないです(1/2)。
  デプロイ中またはタスクが起動失敗している可能性があります。
  ログを確認します。

  [Tool: search_cloudwatch_errors]
  logGroupName=/ecs/api-service, minutesAgo=15

  Found 3 events:
  [2026-03-01T10:16:01Z] ERROR: Cannot connect to database: ECONNREFUSED
  [2026-03-01T10:16:02Z] ERROR: Health check failed
  [2026-03-01T10:16:03Z] ERROR: Task stopped due to health check failures

  データベース接続エラーが原因でタスクが停止しています。
  RDS のステータスか、DATABASE_URL の環境変数設定を確認することを推奨します。
  ECS タスクのスケール変更が必要な場合はお知らせください(確認の上で実行します)。

Part 4:「任せる領域」と「人間が判断する領域」の設計

AIエージェントへの委譲は「全部任せる」ではなく、操作の種類によって明確に境界を引く必要がある。

委譲の境界設計:

  ✅ AIに全委譲してよい操作(読み取り専用):
    - CloudWatch ログの検索・集計
    - ECS サービスのステータス確認
    - Cost Explorer でのコスト照会
    - S3 バケットの一覧・オブジェクト数の確認
    - ECR イメージの一覧取得
    → 何も変更しないので、誤判断しても被害なし

  ⚠️  AIが提案し、人間が承認する操作:
    - ECR 古いイメージの削除(dry_run→確認→実行の2ステップ)
    - ECS desired count の変更(スケール)
    - S3 オブジェクトの削除
    → AIが「何を、なぜ変更するか」を提示し、人間がGoを出す

  🚫 AIに渡さないToolとして定義しない操作:
    - terraform apply の実行
    - IAM ポリシーの変更
    - RDS インスタンスの削除
    - S3 バケット自体の削除
    - 本番環境の環境変数変更
    → MCP Server のコード上にToolとして存在させない
      = AIが「道具がない」ので実行できない構造

この設計の核心は、「AIが賢くなっても、Toolの権限が上限を決める」 という点だ。AIがどれだけ正確に判断できても、Toolとして実装されていない操作は実行できない。


Part 5:MCPサーバーのセキュリティ設計

ローカル実行時の認証

// 環境変数から AWS 認証情報を取得(ハードコード禁止)
// ~/.aws/credentials の named profile か IAM Instance Profile を使う
const ecs = new ECSClient({
  region: process.env.AWS_REGION,
  // credentials は SDK が自動解決(環境変数・profile・IMDS の順)
});

// MCP Server に渡す IAM ポリシーは最小権限で
// 読み取り専用Toolなら ReadOnly ポリシーのみ
// 変更系Toolは対象リソースを ARN で絞る

CI/CD サーバーや共有環境での利用

# GitHub Actions での MCP Server 経由のインフラ操作(将来的なユースケース)
# 現時点では Claude Desktop / Cursor でのローカル利用が主流

# セキュリティ上の注意:
# - MCP Server のプロセスに渡す認証情報は最小権限の IAM Role
# - stdio transport はプロセス間通信のため、ネットワーク露出なし
# - HTTP transport を使う場合は mTLS または private network 内に限定

Zod によるInput Validation

// 全 Tool の引数を Zod で厳密にバリデート
// AI が不正な引数(負の desired count 等)を渡しても弾かれる

const { desiredCount } = z.object({
  desiredCount: z.number().min(0).max(100),  // 0〜100 の範囲外は拒否
}).parse(args);

Conclusion:MCPは「自動化」ではなく「委譲の設計」

MCPを使ったAIエージェント活用を「自動化」と捉えると、設計を誤る。正確には 「意思決定の委譲範囲をコードで定義する」 作業だ。

自動化との違い:

  自動化(スクリプト・cron):
    「この手順を毎回実行する」
    → 判断なし、実行のみ
    → 状況の変化に対応できない

  AIへの委譲(MCP):
    「この道具を使って、状況を判断して、適切に対応して」
    → 状況を読んで次のToolを選ぶ
    → 想定外の結果が出たら別のアプローチを試みる
    → ただし道具の範囲(=権限)は人間が設計する
観点 スクリプト自動化 MCP + AI Agent
定型手順の実行 ✅ 得意 ✅ 得意(かつ自然言語で指示可能)
状況に応じた判断 ❌ 苦手 ✅ 得意(ログの内容を読んで次の手を考える)
権限の制御 IAM で制御 IAM + Tool定義の2層で制御
実行の透明性 ログのみ AIの「思考過程」が可視化される
破壊的操作の安全性 スクリプトの品質次第 dry_run + 人間承認ステップを構造化できる

「AIにインフラを触らせるのは怖い」という直感は正しい。だからこそ、怖い操作をToolとして定義しないという設計判断が最初に必要だ。MCPは「AIに何を許すか」を明示的にコードで表現する仕組みだ。

この記事をシェア

Twitter / X LinkedIn
記事一覧に戻る
🤖
Cloud Assistant
IBM Watson powered

こんにちは!クラウドエンジニアのポートフォリオサイトへようこそ。AWS構成・副業サービス・お仕事のご相談など、何でも聞いてください 👋