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に何を許すか」を明示的にコードで表現する仕組みだ。
この記事をシェア