【Claude MCP】自然言語でAWSリソースを操作する
1. はじめに
アクセンチュア株式会社の桐山です。
今回は、独自のMCPサーバーを実装してみましたので、紹介したいと思います。
MCP(Model Context Protocol) とは、以下の記事でも紹介しましたが、LLMと様々なリソース・データソースとを接続する標準的なプロトコルです。
既にClaude MCP公式からは、ファイル操作、ブラウザ操作、データベース操作等のMCPサーバーが公開されており、これらを利用することができます。しかし、その他のリソースと接続するためには、独自のMCPサーバーを実装する必要があります。
今回はその一例として、AWSのEC2を操作するMCPサーバーを実装してみました。
2. ゴール
まず冒頭、今回のゴールを示したいと思います。
-
Claudeデスクトップで「全てのEC2の状態を確認して」のプロンプトにより、EC2の状態を確認することができました!
-
「ec2-lnx-01を起動して」のプロンプトにより、対象のEC2を起動することができました!
-
「全てのEC2を停止して」のプロンプトにより、対象のEC2を停止することができました!
3. インストール
今回は、Windows上のuv
による仮想環境で進めていきたいと思います。
-
uv
をインストール
winget install --id=astral-sh.uv -e
-
create-mcp-server
でMCPサーバーのテンプレートを作成
下記コマンド入力後、プロジェクト名やバージョン等求められますので、指示に従い入力を進めます
uvx create-mcp-server --path boto3-ec2
create-mcp-server で生成されたテンプレート (\<プロジェクト名>\src\server.py)
import asyncio
from mcp.server.models import InitializationOptions
import mcp.types as types
from mcp.server import NotificationOptions, Server
from pydantic import AnyUrl
import mcp.server.stdio
# Store notes as a simple key-value dict to demonstrate state management
notes: dict[str, str] = {}
server = Server("boto3-ec2")
@server.list_resources()
async def handle_list_resources() -> list[types.Resource]:
"""
List available note resources.
Each note is exposed as a resource with a custom note:// URI scheme.
"""
return [
types.Resource(
uri=AnyUrl(f"note://internal/{name}"),
name=f"Note: {name}",
description=f"A simple note named {name}",
mimeType="text/plain",
)
for name in notes
]
@server.read_resource()
async def handle_read_resource(uri: AnyUrl) -> str:
"""
Read a specific note's content by its URI.
The note name is extracted from the URI host component.
"""
if uri.scheme != "note":
raise ValueError(f"Unsupported URI scheme: {uri.scheme}")
name = uri.path
if name is not None:
name = name.lstrip("/")
return notes[name]
raise ValueError(f"Note not found: {name}")
@server.list_prompts()
async def handle_list_prompts() -> list[types.Prompt]:
"""
List available prompts.
Each prompt can have optional arguments to customize its behavior.
"""
return [
types.Prompt(
name="summarize-notes",
description="Creates a summary of all notes",
arguments=[
types.PromptArgument(
name="style",
description="Style of the summary (brief/detailed)",
required=False,
)
],
)
]
@server.get_prompt()
async def handle_get_prompt(
name: str, arguments: dict[str, str] | None
) -> types.GetPromptResult:
"""
Generate a prompt by combining arguments with server state.
The prompt includes all current notes and can be customized via arguments.
"""
if name != "summarize-notes":
raise ValueError(f"Unknown prompt: {name}")
style = (arguments or {}).get("style", "brief")
detail_prompt = " Give extensive details." if style == "detailed" else ""
return types.GetPromptResult(
description="Summarize the current notes",
messages=[
types.PromptMessage(
role="user",
content=types.TextContent(
type="text",
text=f"Here are the current notes to summarize:{detail_prompt}\n\n"
+ "\n".join(
f"- {name}: {content}"
for name, content in notes.items()
),
),
)
],
)
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
"""
List available tools.
Each tool specifies its arguments using JSON Schema validation.
"""
return [
types.Tool(
name="add-note",
description="Add a new note",
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string"},
"content": {"type": "string"},
},
"required": ["name", "content"],
},
)
]
@server.call_tool()
async def handle_call_tool(
name: str, arguments: dict | None
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
"""
Handle tool execution requests.
Tools can modify server state and notify clients of changes.
"""
if name != "add-note":
raise ValueError(f"Unknown tool: {name}")
if not arguments:
raise ValueError("Missing arguments")
note_name = arguments.get("name")
content = arguments.get("content")
if not note_name or not content:
raise ValueError("Missing name or content")
# Update server state
notes[note_name] = content
# Notify clients that resources have changed
await server.request_context.session.send_resource_list_changed()
return [
types.TextContent(
type="text",
text=f"Added note '{note_name}' with content: {content}",
)
]
async def main():
# Run the server using stdin/stdout streams
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="boto3-ec2",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
- 必要なライブラリをインストール
uv add boto3 python-dotenv
4. 実装
実装と言いましても、今回は Claude 3.5 Sonnet にコードを書いてもらいました。
Claude 3.5 Sonnet のカットオフは2024年4月ですので、MCPについて学習されていません。
そこで、以下のMCP公式情報やサンプルコードを追加のナレッジとして Claude 3.5 Sonnet に与えました。
以下が Claude 3.5 Sonnet により生成されたMCPサーバーのPythonコードとなります。(<プロジェクト名>\src\server.py)
import os
import json
import logging
from typing import Any, Sequence
import boto3
from botocore.exceptions import ClientError
from dotenv import load_dotenv
from mcp.server import Server
from mcp.types import (
Resource,
Tool,
TextContent,
EmbeddedResource
)
from pydantic import AnyUrl
# 環境変数の読み込み
load_dotenv()
# ログの設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ec2-mcp-server")
# 必要な環境変数の確認
required_env_vars = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
missing_env_vars = [var for var in required_env_vars if not os.getenv(var)]
if missing_env_vars:
raise ValueError(f"Missing required environment variables: {', '.join(missing_env_vars)}")
# AWSセッションの設定
session = boto3.Session(
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
region_name=os.getenv("AWS_REGION", "ap-northeast-1")
)
# EC2クライアントの初期化
ec2_client = session.client('ec2')
# MCPサーバーの初期化
app = Server("ec2-service-server")
def get_instance_details(instance):
"""EC2インスタンスの詳細情報を取得"""
name = ''
for tag in instance.get('Tags', []):
if tag['Key'] == 'Name':
name = tag['Value']
break
return {
'instance_id': instance['InstanceId'],
'name': name,
'instance_type': instance['InstanceType'],
'state': instance['State']['Name'],
'private_ip': instance.get('PrivateIpAddress', ''),
'public_ip': instance.get('PublicIpAddress', ''),
'launch_time': instance['LaunchTime'].isoformat()
}
@app.list_resources()
async def list_resources() -> list[Resource]:
"""利用可能なEC2リソースの一覧を返す"""
try:
# EC2インスタンスの一覧を取得
response = ec2_client.describe_instances()
resources = []
for reservation in response['Reservations']:
for instance in reservation['Instances']:
instance_details = get_instance_details(instance)
uri = AnyUrl(f"ec2://{instance['InstanceId']}")
resources.append(
Resource(
uri=uri,
name=f"EC2 Instance: {instance_details['name'] or instance['InstanceId']}",
mimeType="application/x-ec2-instance",
description=f"Type: {instance['InstanceType']}, State: {instance['State']['Name']}"
)
)
return resources
except ClientError as e:
logger.error(f"Failed to list EC2 instances: {str(e)}")
return []
@app.read_resource()
async def read_resource(uri: AnyUrl) -> str:
"""指定されたEC2インスタンスの詳細情報を返す"""
if not str(uri).startswith("ec2://"):
raise ValueError(f"Unsupported URI scheme: {uri}")
try:
instance_id = str(uri)[6:]
response = ec2_client.describe_instances(
InstanceIds=[instance_id]
)
if not response['Reservations']:
raise ValueError(f"Instance not found: {instance_id}")
instance = response['Reservations'][0]['Instances'][0]
instance_details = get_instance_details(instance)
return json.dumps(instance_details, indent=2)
except ClientError as e:
logger.error(f"Failed to read EC2 instance: {str(e)}")
raise RuntimeError(f"EC2 access error: {str(e)}")
@app.list_tools()
async def list_tools() -> list[Tool]:
"""利用可能なEC2ツールの一覧を返す"""
return [
Tool(
name="list_instances",
description="List EC2 instances by state",
inputSchema={
"type": "object",
"properties": {
"state": {
"type": "string",
"description": "Instance state (running, stopped, all)",
"enum": ["running", "stopped", "all"]
}
},
"required": ["state"]
}
),
Tool(
name="start_instance",
description="Start a stopped EC2 instance",
inputSchema={
"type": "object",
"properties": {
"instance_id": {
"type": "string",
"description": "EC2 instance ID"
}
},
"required": ["instance_id"]
}
),
Tool(
name="stop_instance",
description="Stop a running EC2 instance",
inputSchema={
"type": "object",
"properties": {
"instance_id": {
"type": "string",
"description": "EC2 instance ID"
}
},
"required": ["instance_id"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: Any) -> Sequence[TextContent | EmbeddedResource]:
"""指定されたEC2ツールを実行"""
if name == "list_instances":
try:
filters = []
if arguments["state"] != "all":
filters.append({
'Name': 'instance-state-name',
'Values': [arguments["state"]]
})
response = ec2_client.describe_instances(Filters=filters)
instances = []
for reservation in response['Reservations']:
for instance in reservation['Instances']:
instances.append(get_instance_details(instance))
return [
TextContent(
type="text",
text=json.dumps(instances, indent=2)
)
]
except ClientError as e:
raise RuntimeError(f"EC2 list error: {str(e)}")
elif name == "start_instance":
try:
response = ec2_client.start_instances(
InstanceIds=[arguments["instance_id"]]
)
return [
TextContent(
type="text",
text=json.dumps({
"message": f"Starting instance {arguments['instance_id']}",
"state_change": response['StartingInstances'][0]['CurrentState']['Name']
}, indent=2)
)
]
except ClientError as e:
raise RuntimeError(f"EC2 start error: {str(e)}")
elif name == "stop_instance":
try:
response = ec2_client.stop_instances(
InstanceIds=[arguments["instance_id"]]
)
return [
TextContent(
type="text",
text=json.dumps({
"message": f"Stopping instance {arguments['instance_id']}",
"state_change": response['StoppingInstances'][0]['CurrentState']['Name']
}, indent=2)
)
]
except ClientError as e:
raise RuntimeError(f"EC2 stop error: {str(e)}")
else:
raise ValueError(f"Unknown tool: {name}")
async def main():
from mcp.server.stdio import stdio_server
async with stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options()
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Claudeデスクトップの claude_desktop_config.json に、以下を指定します。(パスは任意)
{
"mcpServers": {
"boto3-ec2": {
"command": "uv",
"args": [
"--directory",
"C:\\MCP\\boto3-ec2",
"run",
"boto3-ec2"
],
"env": {
"AWS_ACCESS_KEY_ID": <YOUR_AWS_ACCESS_KEY_ID>,
"AWS_SECRET_ACCESS_KEY": <YOUR_AWS_SECRET_ACCESS_KEY>
}
}
}
}
5. さいごに
いかがでしたでしょうか。
今回は、Claude MCPを活用してAWS EC2を操作する独自MCPサーバーの実装を試してみました。
今回試しましたEC2の操作はあくまで一例であり、MCPを活用することでLLMからさまざまなリソースを操作することが可能となります。(制約はありますが)
次回以降、MCPによる他のリソース操作も試してみたいと思います。
Discussion