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.

Prerequisites: Lesson 4 complete (a working Strands agent with a local tool), Lesson 1 concepts (tool, agent loop)

Cost: Free to run. The demo connects to the public AWS Knowledge MCP Server over the network, which needs no AWS account and no credentials, and the allow-list functions run locally with no network at all.

Verified on: Jul 15, 2026

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 MCPClient for 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_system tool.
  • The course code checked out with uv sync run once from agent/.

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.

  1. Read the client (mcp_client.py, below). Note the pinned server URL, the pinned protocol version, and the ALLOWED_MCP_TOOLS set of exactly two read-only tool names.

  2. Note the allow-list functions. is_allowed, filter_allowed_tools, and require_allowed are pure functions with no network, so the security rule is testable offline (you will test it in Lesson 7).

  3. Run the demo:

    uv run python -m intake.mcp_client

    It 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 example aws___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.

  1. Run uv run python -m intake.mcp_client and read the “Discovered tools” line. Confirm the server offers more tools than the two the course allows, and note the aws___ prefix on the real names.
  2. 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.
  3. Open mcp_client.py and change the string in the last require_allowed call in the __main__ block from "aws___retrieve_skill" to "aws___search_documentation". Run again. That name is on the allow-list, so require_allowed returns 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.

Go deeper (2)