Connect an allow-listed MCP tool
Outcome: You connect to a real remote MCP server, list the tools it offers, and see a default-deny allow-list keep everything except two read-only tools out — while a non-allow-listed action is refused before any call.
Outcome
By the end of this lesson you will connect the agent to a tool that lives on a separate server you did not write. You will list the tools that server offers, and you will see a deterministic allow-list act as a gate: only tools you named in advance are allowed to reach the agent, and any other action is refused before a single call is made. The demo talks to a real, public server over the network, so this is not a simulation. It costs nothing and needs no AWS account.
This is the first time the agent reaches outside your own code for a capability. That is powerful, and it is exactly why this lesson spends most of its time on the boundary, not the feature.
Mental model
MCP stands for Model Context Protocol. It is a shared language that lets an agent use tools hosted by a separate program, the same way a visual automation tool uses a connector to reach a service it did not build. MCP is the connector standard for agents: instead of one custom integration per service, a server speaks MCP and any MCP-aware agent can discover and call its tools.
Five words name the pieces you will see in the code:
- Host: the application the learner runs. Here it is your Python program.
- Client: the part inside the host that speaks MCP to one server. Strands
gives you an
MCPClientfor this. - Server: the separate program that offers the tools. Here it is the AWS (Amazon Web Services) Knowledge MCP Server, a managed, read-only endpoint that returns AWS documentation and regional facts.
- Transport: how the client and server exchange messages. This server uses Streamable HTTP (HyperText Transfer Protocol), a single web endpoint the client posts requests to.
- Tool discovery: the client asks the server, at run time, which tools it offers. You do not hard-code the list; you request it and inspect it.
The sixth word is the whole point of the lesson: trust boundary. The server is code you did not write and do not control. Its tool list, every tool description, and everything a tool returns are untrusted input — data to be checked, never instructions to be obeyed. In a visual tool you mostly trust your connectors. Here you must not. So you place a deterministic allow-list between the server and the agent: a fixed set of tool names you have reviewed and chosen. Anything not on the list is denied by default, even if the server offers it.
Where the analogy stops: a normal connector is something you configured and trust. An MCP server is a live boundary that can change what it offers between calls, so you re-check its real tool names against your allow-list every time, and you review the server’s published source before you trust it at all.
Prerequisites and cost
- Lesson 4 complete: you can run the local Strands agent and its
lookup_systemtool. - The course code checked out with
uv syncrun once fromagent/.
Cost: this lesson is free to run. The demo connects to the public AWS Knowledge
MCP Server, which needs no AWS account and no credentials and only returns
documentation, so it cannot touch or bill your AWS resources. The allow-list
functions (is_allowed, filter_allowed_tools, require_allowed) run locally
with no network. The one real limit is that the public endpoint applies rate
limits; see “One common failure” below.
Steps
Run these from the agent/ folder.
-
Read the client (
mcp_client.py, below). Note the pinned server URL, the pinned protocol version, and theALLOWED_MCP_TOOLSset of exactly two read-only tool names. -
Note the allow-list functions.
is_allowed,filter_allowed_tools, andrequire_allowedare pure functions with no network, so the security rule is testable offline (you will test it in Lesson 7). -
Run the demo:
uv run python -m intake.mcp_clientIt connects to the server, lists the discovered tools, filters them through the allow-list, and then demonstrates that a non-allow-listed action is refused.
Smallest code sample
Read mcp_client.py top to bottom. The server URL and the MCP protocol version
are pinned as constants, not passed as “latest,” so the client always speaks a
known version to a known endpoint. ALLOWED_MCP_TOOLS is the allow-list: two
read-only tools and nothing else. filter_allowed_tools keeps only discovered
tools whose name is on that list, so a tool the server adds later is excluded
until someone reviews it and adds it here on purpose. require_allowed raises
MCPToolDenied for any name not on the list, so code cannot call a tool it never
approved. Connecting to the live server happens only inside the __main__ demo,
within a with block that opens and closes the connection.
"""Lesson 5: an allow-listed, read-only MCP client (AWS Knowledge MCP Server).
MCP (Model Context Protocol) lets the agent use tools hosted by a separate server.
That server is a trust boundary: we did not write it, so we treat its tool list
and its returned content as untrusted data, and we only let the agent see the
subset of tools we explicitly allow.
The server here is the AWS Knowledge MCP Server: a managed, remote, read-only
endpoint from AWS that returns documentation and regional facts. It needs no AWS
credentials, so it cannot be a path to privileged AWS actions.
`filter_allowed_tools` / `is_allowed` are the deterministic allow-list gate and are
pure functions, so tests exercise the security rule with fake tools and no network.
Connecting to the live server happens only in the __main__ demo.
"""
from __future__ import annotations
from typing import Any
# Reviewed source of truth for the tool contract: awslabs/mcp,
# src/aws-knowledge-mcp-server. Pin the protocol version we speak.
AWS_KNOWLEDGE_MCP_URL = "https://knowledge-mcp.global.api.aws"
MCP_PROTOCOL_VERSION = "2025-11-25"
# The server offers more tools than this (aws___list_regions,
# aws___get_regional_availability, aws___retrieve_skill). We deliberately allow
# only the two the intake agent needs for context. Anything else is denied even
# though the server exposes it.
#
# Names must match what the server actually advertises — this server namespaces
# every tool with an "aws___" prefix (verified against the live endpoint on
# 2026-07-15). An allow-list of bare names silently allows nothing: safe
# (default-deny fails closed) but useless.
ALLOWED_MCP_TOOLS = frozenset({"aws___search_documentation", "aws___read_documentation"})
class MCPToolDenied(PermissionError):
"""Raised when code asks for an MCP tool that is not on the allow-list."""
def _tool_name(tool: Any) -> str:
"""Read the agent-facing name off a Strands MCP tool object."""
return getattr(tool, "tool_name", None) or getattr(tool, "name", "")
def is_allowed(name: str, allowlist: frozenset[str] = ALLOWED_MCP_TOOLS) -> bool:
"""Return True only if `name` is explicitly on the allow-list."""
return name in allowlist
def filter_allowed_tools(
discovered: list[Any],
allowlist: frozenset[str] = ALLOWED_MCP_TOOLS,
) -> list[Any]:
"""Keep only discovered tools whose name is on the allow-list.
Default-deny: a tool the server adds later is excluded until we allow it here.
"""
return [tool for tool in discovered if is_allowed(_tool_name(tool), allowlist)]
def require_allowed(name: str, allowlist: frozenset[str] = ALLOWED_MCP_TOOLS) -> str:
"""Return `name` if allowed, else raise MCPToolDenied. Use before any call."""
if not is_allowed(name, allowlist):
raise MCPToolDenied(
f"Tool '{name}' is not on the MCP allow-list "
f"({sorted(allowlist)}). Refusing to call it."
)
return name
def build_mcp_client():
"""Create an MCPClient for the AWS Knowledge server over Streamable HTTP.
Use inside a `with` block; the connection is only open within that context.
Imports are lazy so the allow-list logic imports without the MCP stack.
"""
from mcp.client.streamable_http import streamablehttp_client
from strands.tools.mcp import MCPClient
return MCPClient(
lambda: streamablehttp_client(
AWS_KNOWLEDGE_MCP_URL,
headers={"MCP-Protocol-Version": MCP_PROTOCOL_VERSION},
)
)
if __name__ == "__main__":
# This reaches the public AWS Knowledge MCP endpoint. It needs no credentials
# and is read-only, but it is a network call subject to rate limits.
print("NOTE: connecting to the public AWS Knowledge MCP Server (read-only).\n")
client = build_mcp_client()
with client:
discovered = client.list_tools_sync()
names = [_tool_name(t) for t in discovered]
print("Discovered tools:", names)
allowed = filter_allowed_tools(discovered)
print("Allowed tools:", [_tool_name(t) for t in allowed])
# One successful allowed call, bounded: one result, and we print only
# the first 300 characters. Server output is untrusted data — display
# it, never execute or obey it.
result = client.call_tool_sync(
tool_use_id="lesson5-demo",
name=require_allowed("aws___search_documentation"),
arguments={"search_phrase": "Amazon Bedrock AgentCore Runtime", "limit": 1},
)
text = str(result.get("content", result))[:300]
print(f"Allowed call result (first 300 chars): {text}\n")
# Demonstrate a denied, non-allow-listed call. `aws___retrieve_skill` is
# a real server tool we chose not to allow.
try:
require_allowed("aws___retrieve_skill")
except MCPToolDenied as err:
print("Denied as expected:", err)
Expected output
This demo makes a real network call, so the exact tool list can change if AWS updates the server. This is the real output captured when this lesson was written (verified on 2026-07-15):
NOTE: connecting to the public AWS Knowledge MCP Server (read-only).
Discovered tools: ['aws___read_documentation', 'aws___search_documentation', 'aws___list_regions', 'aws___get_regional_availability', 'aws___retrieve_skill']
Allowed tools: ['aws___read_documentation', 'aws___search_documentation']
Allowed call result (first 300 chars): [{'text': '{"content":{"result":[{"rank_order":1,"title":"Building intelligent media supply chain automation using Amazon Bedrock AgentCore | AWS for M&E Blog","context":"### Deploy Sports Agent to AgentCore Runtime\n\nYou've now built an agent locally using Strands and scaled your tools with Agen
Denied as expected: Tool 'aws___retrieve_skill' is not on the MCP allow-list (['aws___read_documentation', 'aws___search_documentation']). Refusing to call it.
The search result’s text will differ on your run — the server ranks live documentation, and AWS updates it. The four lines to check are:
- Discovered tools shows the five tools the server really offers right now.
Notice that the live server advertises each name under an
aws___namespace prefix, for exampleaws___search_documentation. You did not decide those names; the server did. That is tool discovery: you asked, and this is the untrusted answer. - Allowed tools shows exactly two entries: the allow-list kept the two documentation tools and dropped the other three, because they are not on the list. A tool the server adds tomorrow would be dropped the same way until a human reviews it.
- Allowed call result is one real, bounded call through the gate: one search result requested, and only the first 300 characters printed. What comes back is untrusted data — the demo displays it and never treats it as instructions.
- Denied as expected shows
require_allowed("aws___retrieve_skill")refusing a real server tool the course chose not to allow. The refusal happens in your code, before any network call, which is the point: an unapproved action never reaches the server.
The checkpoint for this lesson is these observations together: you inspected the tool list, one allow-listed call succeeded, and a non-allow-listed action was denied.
One common failure
Symptom: “Discovered tools” prints normally, but Allowed tools: [] is
empty, and the allowed call fails with MCPToolDenied — even though the tools
you want are plainly in the discovered list.
Diagnosis: your allow-list entries do not exactly match the names the server
advertises. This really happened while building this course: the allow-list
originally held the plain names (search_documentation), but the live server
advertises prefixed names (aws___search_documentation). The match is exact and
default-deny, so nothing passed. Note the safety direction: the gate let nothing
through rather than guessing. An allow-list mismatch fails closed, never open.
Fix: read the “Discovered tools” line, review the tools you actually need on
the server’s own documentation (the awslabs/mcp repository), and put the exact
advertised names on ALLOWED_MCP_TOOLS — never the names you assumed. If instead
of the tool list you get a connection error or an HTTP 429 (too many requests),
that is the shared public endpoint’s rate limit or a blocked network: wait and
retry; the allow-list functions themselves never touch the network.
Why this works
The agent gains an external capability without gaining external risk, because
everything the server says passes through a gate you control. The server URL and
protocol version are pinned, so the client always speaks a known version to a
reviewed endpoint (the source of truth for that endpoint’s tool contract is the
awslabs/mcp repository, which you review before trusting it). Tool discovery is
treated as untrusted data: you read the names the server advertises, you do not
assume them. The allow-list is default-deny and matches exact names, so a renamed,
added, or namespaced tool is excluded until a human reviews it and adds it on
purpose — the common failure above shows that gate failing closed in real life.
require_allowed refuses an unapproved action in your own code before any call
leaves your machine. And because the chosen tools are read-only documentation
lookups that need no credentials, even a successful call cannot mutate an AWS
resource. Capability where it helps, a hard boundary where it matters.
Verify it yourself
The checkpoint is: inspect the tool list, make one allowed call, and see a denied action.
- Run
uv run python -m intake.mcp_clientand read the “Discovered tools” line. Confirm the server offers more tools than the two the course allows, and note theaws___prefix on the real names. - Confirm the “Allowed call result” line printed a real search result, and that
the “Denied as expected” line refused
aws___retrieve_skill. That refusal is your code enforcing the allow-list before any network call. - Open
mcp_client.pyand change the string in the lastrequire_allowedcall in the__main__block from"aws___retrieve_skill"to"aws___search_documentation". Run again. That name is on the allow-list, sorequire_allowedreturns it instead of raising, and the “Denied as expected” line no longer prints. This shows the gate is a real decision, not a fixed message. Restore the original line when you are done.
Seeing an unapproved tool refused and an approved name accepted is this lesson’s checkpoint: the boundary is a deterministic gate you control, not something the server decides.
Cleanup
Nothing to delete. The demo is a read-only network call that finishes
immediately, and it created no AWS resource because the server needs no account
and cannot write anything. There are no credentials to revoke, since the endpoint
uses none. If you edited the __main__ block to test step 3, restore it or leave
it as your own note. You now have an agent that can reach a tool it did not write,
behind an allow-list that keeps that reach bounded.


