
Model Context Protocol (MCP) and Amazon Bedrock
Getting started with Anthropic's LLM protocol on AWS
MCP is an open protocol released by Anthropic that standardizes how applications provide context to LLMs. Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools.
- Stdio transport
- Uses standard input/output for communication
- Ideal for local processes
- HTTP with SSE transport
- Uses Server-Sent Events for server-to-client messages
- HTTP POST for client-to-server messages
We'll follow the journey of the simple prompt
get me a summary of the blog post at this $URL
depicted in the two pictures below step by step, so it's easy to follow along.- For a human the task of reading a web page and provide its summary is trivial, but LLMs are not normally able to visit web pages and fetch context outside of their parametric memory. This is why we need a tool.
- We provide the user prompt and a list of available tools brokered by our MCP server to Amazon Bedrock via Converse API. In this case, Amazon Bedrock is acting as our unified interface to many models.
- Based on the user prompt and the tool inventory the chosen model plans a proper response.
- In this case the model correctly plans to use the
visit_webpage
tool to download the content at the URL provided. Bedrock returns atoolUse
message to the client, including thename
of the selected tool, theinput
for the tool request, and a uniquetoolUseId
which can be used in subsequent messages. Read these docs for more information about the syntax and usage oftoolUse
in Bedrock Converse API. - The client is programmed to forward any
toolUse
message to the MCP server.
In our implementation, communication happens viaJSON-RPC
overstdio
on the same machine - The MCP server dispatches the
toolUse
request to the appropriate tool visit_webpage
tool is invoked and an HTTP request is made to the provided URL- The tool is programmed to download the content located at the provided URL and return its content in markdown format
- The content is then forwarded to the MCP server
- Flow control is returned to the MCP client. We complete the journey with steps 11-14 depicted in the following picture.Image not found
- The MCP client adds a
toolResult
message to the conversation history inclding thetoolUseId
provided at step 4 and forwards it to Bedrock. Read these docs for more information abouttoolResult
syntax. - Bedrock now plans to use the result of the tool to compose its final response
- The response is sent back to the client which is programmed to yield back the control of the conversation flow to the user
- the user receives the response from the MCP client and the user is free to initiate a new flow
- Complete this tutorial showing how to create an MCP server in Python. At the end of the tutorial you should have a working MCP server providing two tools:
get_alerts
andget_weather
. This also includes installinguv
, a fast and secure Python runtime and package manager. - make sure you've exported your AWS credentials in your environment, so that they're available to
boto3
💡 For more information on how to do this, please refer to the AWS Boto3 documentation (Developer Guide > Credentials).
mcp-client
folder we're going to create now)weather/
and create a new python project1
2
cd ..
uv init mcp-client
main.py
and create a new file called client.py
by1
2
3
cd mcp-client
rm main.py
touch client.py
uv
1
uv add mcp boto3
mcp
package to manage access to the MCP server session and of course a sprinkle of boto3
to add the Bedrock goodness.1
2
3
4
5
6
7
8
9
10
11
12
13
# client.py
import asyncio
import sys
from typing import Optional, List, Dict, Any
from contextlib import AsyncExitStack
from dataclasses import dataclass
# to interact with MCP
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# to interact with Amazon Bedrock
import boto3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# client.py
class Message:
role: str
content: List[Dict[str, Any]]
def user(cls, text: str) -> 'Message':
return cls(role="user", content=[{"text": text}])
def assistant(cls, text: str) -> 'Message':
return cls(role="assistant", content=[{"text": text}])
def tool_result(cls, tool_use_id: str, content: dict) -> 'Message':
return cls(
role="user",
content=[{
"toolResult": {
"toolUseId": tool_use_id,
"content": [{"json": {"text": content[0].text}}]
}
}]
)
def tool_request(cls, tool_use_id: str, name: str, input_data: dict) -> 'Message':
return cls(
role="assistant",
content=[{
"toolUse": {
"toolUseId": tool_use_id,
"name": name,
"input": input_data
}
}]
)
def to_bedrock_format(tools_list: List[Dict]) -> List[Dict]:
return [{
"toolSpec": {
"name": tool["name"],
"description": tool["description"],
"inputSchema": {
"json": {
"type": "object",
"properties": tool["input_schema"]["properties"],
"required": tool["input_schema"]["required"]
}
}
}
} for tool in tools_list]
MCPClient
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# client.py
class MCPClient:
MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"
def __init__(self):
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
self.bedrock = boto3.client(service_name='bedrock-runtime', region_name='us-east-1')
async def connect_to_server(self, server_script_path: str):
if not server_script_path.endswith(('.py', '.js')):
raise ValueError("Server script must be a .py or .js file")
command = "python" if server_script_path.endswith('.py') else "node"
server_params = StdioServerParameters(command=command, args=[server_script_path], env=None)
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
await self.session.initialize()
response = await self.session.list_tools()
print("\nConnected to server with tools:", [tool.name for tool in response.tools])
async def cleanup(self):
await self.exit_stack.aclose()
self.session
is the object mapping to the MCP session we're establishing. In this case we'll be usingstdio
, as we'll be using tools hosted on the same machineself.bedrock
creates an AWS SDK client that provides methods to interact with Amazon Bedrock's runtime APIs, allowing you to make API calls like converse to communicate with foundation models.self.exit_stack = AsyncExitStack()
creates a context manager that helps manage multiple async resources (like network connections and file handles) by automatically cleaning them up in reverse order when the program exits, similar to a stack of nestedasync with
statements but more flexible and programmatic. We're making use ofself.exit_stack
in the publiccleanup
method to cut loose ends.- The
connect_to_server
method establishes a bidirectional communication channel with a Python or Node.js script that implements MCP tools, using standard input/output (stdio) for message passing, and initializes a session that allows the client to discover and call the tools exposed by the server script.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# client.py
def _make_bedrock_request(self, messages: List[Dict], tools: List[Dict]) -> Dict:
return self.bedrock.converse(
modelId=self.MODEL_ID,
messages=messages,
inferenceConfig={"maxTokens": 1000, "temperature": 0},
toolConfig={"tools": tools}
)
async def process_query(self, query: str) -> str:
# (1)
messages = [Message.user(query).__dict__]
# (2)
response = await self.session.list_tools()
# (3)
available_tools = [{
"name": tool.name,
"description": tool.description,
"input_schema": tool.inputSchema
} for tool in response.tools]
bedrock_tools = Message.to_bedrock_format(available_tools)
# (4)
response = self._make_bedrock_request(messages, bedrock_tools)
# (6)
return await self._process_response( # (5)
response, messages, bedrock_tools
)
_make_bedrock_request
method is a private helper that sends a request to Amazon Bedrock's Converse API, passing in the conversation history ( messages), available tools, and model configuration parameters (like token limit and temperature), to get a response from the foundation model for the next turn of conversation. We'll use this in a couple of different methodsprocess_query
method orchestrates the entire query processing flow:- Creates a message from the user's query
- Fetches available tools from the connected server
- Formats the tools into Bedrock's expected structure
- Makes a request to Bedrock with the query and tools
- Processes the response through potentially multiple turns of conversation (if tool use is needed)
- Returns the final response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# client.py
async def _process_response(self, response: Dict, messages: List[Dict], bedrock_tools: List[Dict]) -> str:
# (1)
final_text = []
MAX_TURNS=10
turn_count = 0
while True:
# (2)
if response['stopReason'] == 'tool_use':
final_text.append("received toolUse request")
for item in response['output']['message']['content']:
if 'text' in item:
final_text.append(f"[Thinking: {item['text']}]")
messages.append(Message.assistant(item['text']).__dict__)
elif 'toolUse' in item:
# (3)
tool_info = item['toolUse']
result = await self._handle_tool_call(tool_info, messages)
final_text.extend(result)
response = self._make_bedrock_request(messages, bedrock_tools)
# (4)
elif response['stopReason'] == 'max_tokens':
final_text.append("[Max tokens reached, ending conversation.]")
break
elif response['stopReason'] == 'stop_sequence':
final_text.append("[Stop sequence reached, ending conversation.]")
break
elif response['stopReason'] == 'content_filtered':
final_text.append("[Content filtered, ending conversation.]")
break
elif response['stopReason'] == 'end_turn':
final_text.append(response['output']['message']['content'][0]['text'])
break
turn_count += 1
if turn_count >= MAX_TURNS:
final_text.append("\n[Max turns reached, ending conversation.]")
break
# (5)
return "\n\n".join(final_text)
_process_response
method initializes a conversation loop with a maximum of 10 turns (MAX_TURNS
), tracking responses infinal_text
.- When the model requests to use a tool, it processes the request by handling both thinking steps (text) and tool execution steps (toolUse).
- For tool usage, it calls the tool handler and makes a new request to Bedrock with the tool's results. Remember, we're hosting the tools locally in our MCP server.
- We also handle various stop conditions (max tokens, content filtering, stop sequence, end turn) by appending appropriate messages and breaking the loop.
- Finally, it joins all accumulated text with newlines and returns the complete conversation history.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# client.py
async def _handle_tool_call(self, tool_info: Dict, messages: List[Dict]) -> List[str]:
# (1)
tool_name = tool_info['name']
tool_args = tool_info['input']
tool_use_id = tool_info['toolUseId']
# (2)
result = await self.session.call_tool(tool_name, tool_args)
# (3)
messages.append(Message.tool_request(tool_use_id, tool_name, tool_args).__dict__)
messages.append(Message.tool_result(tool_use_id, result.content).__dict__)
# (4)
return [f"[Calling tool {tool_name} with args {tool_args}]"]
_handle_tool_call
method executes a tool request by extracting the tool's name, arguments, and ID from the provided info.- It calls the tool through the
session
interface and awaits its result. - The method records both the tool request and its result in the conversation history. This is to let Bedrock know that we have had a conversation with somebody else, somewhere else (the tool running on your machine, I mean!)
- Finally, it returns a formatted message indicating which tool was called with what arguments.
chat_loop
method implements a simple interactive command-line interface that continuously accepts user input, processes queries through the system, and displays responses until the user types 'quit' or an error occurs.1
2
3
4
5
6
7
8
9
10
11
12
13
# client.py
async def chat_loop(self):
print("\nMCP Client Started!\nType your queries or 'quit' to exit.")
while True:
try:
query = input("\nQuery: ").strip()
if query.lower() == 'quit':
break
response = await self.process_query(query)
print("\n" + response)
except Exception as e:
print(f"\nError: {str(e)}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# client.py
async def main():
if len(sys.argv) < 2:
print("Usage: python client.py <path_to_server_script>")
sys.exit(1)
client = MCPClient()
try:
await client.connect_to_server(sys.argv[1])
await client.chat_loop()
finally:
await client.cleanup()
if __name__ == "__main__":
asyncio.run(main())
- The weather alert tool will help us fetching alerts for a state in the US
- prompt: "get me a summary of the weather alerts in California"
- The weather forecast tool will help us getting the forecast for a city in the US
- prompt: "get me a summary of the weather forecast for Buffalo, NY"
client.py
and don't forget to pass the path to where you're stored your tool.1
uv run client.py ../weather/weather.py
1
2
cd ../weather
uv add requests markdownify
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# wheather.py (I know, I know...)
import re
import requests
from markdownify import markdownify
from requests.exceptions import RequestException
def visit_webpage(url: str) -> str:
"""Visits a webpage at the given URL and returns its content as a markdown string.
Args:
url: The URL of the webpage to visit.
Returns:
The content of the webpage converted to Markdown, or an error message if the request fails.
"""
try:
# Send a GET request to the URL
response = requests.get(url, timeout=30)
response.raise_for_status() # Raise an exception for bad status codes
# Convert the HTML content to Markdown
markdown_content = markdownify(response.text).strip()
# Remove multiple line breaks
markdown_content = re.sub(r"\n{3,}", "\n\n", markdown_content)
return markdown_content
except RequestException as e:
return f"Error fetching the webpage: {str(e)}"
except Exception as e:
return f"An unexpected error occurred: {str(e)}"
visit_webpage
function is a tool that fetches content from a given URL using HTTP GET requests. It converts the retrieved HTML content into a cleaner Markdown format, removing excessive line breaks and handling edge cases. The function includes comprehensive error handling for both network-related issues and unexpected errors, returning appropriate error messages when something goes wrong.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# weather.py
def validate_links(urls: list[str]) -> list[str, bool]:
"""Validates that the links are valid webpages.
Args:
urls: The URLs of the webpages to visit.
Returns:
A list of the url and boolean of whether or not the link is valid.
"""
output = []
for url in urls:
try:
# Send a GET request to the URL
response = requests.get(url, timeout=30)
response.raise_for_status() # Raise an exception for bad status codes
print('validateResponse',response)
# Check if the response content is not empty
if response.text.strip():
output.append([url, True])
else:
output.append([url, False])
except RequestException as e:
output.append([url, False])
print(f"Error fetching the webpage: {str(e)}")
except Exception as e:
output.append([url, False])
print(f"An unexpected error occurred: {str(e)}")
return output
validate_links
function takes a list of URLs and checks each one to verify if it's a valid, accessible webpage. It attempts to make HTTP GET requests to each URL, considering a link valid if the request succeeds and returns non-empty content. The function returns a list of URL-validity pairs, where each pair contains the URL and a boolean indicating whether the link is valid, with error handling for both network and general exceptions.- Dheeraj Mudgil - Sr. Solutions Architect, AWS Startups UKIR. Thank you for continuously providing kind and patient guidance on security and inspiring the architectural and flow diagrams in this article
- Heikki Tunkelo - Manager, Solutions Architecture, AWS Startups Nordics. Thanks for your bright remarks on organisational and business benefits in adopting MCP, and overall peer review of the article.
- João Galego - Head of AI at Critical Software and many other superimpositions. Thanks for your kind peer review. You keep insisting on the highest standards, even outside of Amazon.
Any opinions in this post are those of the individual author and may not reflect the opinions of AWS.