🔥Migrating VeraResearch to First-Class MCP Tool Support in Strands🔥
aka, let them manage the complexity
This blog series focuses on presenting complex DevOps projects as simple and approachable via plain language and lots of pictures. You can do it!
These articles are supported by readers, please consider subscribing to support me writing more of these articles <3 :)
Hey all!
Here’s the thing about building custom solutions: they work great until the library you depend on ships an update. Then you’re stuck choosing betIen “maintain my clever workaround” or “rewrite everything to match the new API.” Neither option sounds fun.
VeraResearch is an agentic Slack bot built with AWS Strands that connects to six different MCP (Model Context Protocol) servers—GitHub, Atlassian, PagerDuty, Azure, AWS CLI, and Splunk. Each MCP server provides tools the agent can use to ansIr questions and complete tasks. Pull incident data from PagerDuty. Search Jira tickets. List AWS resources. You get the idea.
When you load multiple MCP servers, they inevitably have overlapping tool names. GitHub has get_user. PagerDuty has get_user. The next MCP you install probably has get_user. Without prefixing, the agent crashes when it tries to load the second one.
So I built a solution: a custom PrefixedMCPTool wrapper class that manually prefixed every tool name. github_get_user, pagerduty_get_user, azure_get_user. Problem solved. It worked perfectly.
Except now I owned ~75 lines of code that reached into MCPClient internals, manually managed client lifecycles, extracted tool lists, wrapped them, and returned tuples. Every time Strands updated how MCPClient worked, I had to check if our custom wrapper still functioned. Maintenance debt.
Then Strands PR #895 landed: first-class support for tool prefixing and filtering directly in MCPClient. The library now provides prefix and tool_filters parameters. Just pass a prefix string and optional tool filter instructions, and MCPClient handles everything.
If I’m maintaining custom abstractions, I’m on my own when breaking changes happen. If Strands provides the feature natively, their updates won’t break my code because they own the abstraction.
Let’s walk through removing my custom cruft and use the code the Strands maintainers team wrote.
To skip right to the code changes, see the commit here.
The Old Way: Custom Tool Wrapping with PrefixedMCPTool
The custom solution had three parts: a wrapper class, builder functions for each MCP server, and consumption code in worker_agent.py.
Here’s the PrefixedMCPTool wrapper class:
| class PrefixedMCPTool(AgentTool): | |
| """Wrapper that adds a prefix to an MCP tool's name.""" | |
| def __init__(self, tool, prefix): | |
| super().__init__() | |
| self._original_tool = tool | |
| self._prefix = prefix | |
| original_spec = tool.tool_spec | |
| self._prefixed_spec = original_spec.copy() | |
| self._prefixed_spec["name"] = f"{prefix}{original_spec['name']}" | |
| @property | |
| def tool_spec(self): | |
| return self._prefixed_spec | |
| @property | |
| def tool_name(self): | |
| return self._prefixed_spec["name"] | |
| def stream(self, tool_use, *args, **kwargs): | |
| return self._original_tool.stream(tool_use, *args, **kwargs) |
Every MCP client builder followed the same pattern. Here’s GitHub:
| def build_github_mcp_client(github_token, mode="read_only"): | |
| # Build client | |
| github_mcp_client = MCPClient( | |
| lambda: streamablehttp_client( | |
| "https://api.githubcopilot.com/mcp/", | |
| headers={"Authorization": f"Bearer {github_token}"}, | |
| ) | |
| ) | |
| # Manually start the client | |
| github_client = github_mcp_client.__enter__() | |
| # Extract all tools | |
| all_github_tools = github_client.list_tools_sync() | |
| # Filter for read-only tools | |
| if mode == "read_only": | |
| filtered_tools = [] | |
| for tool in all_github_tools: | |
| tool_name = tool.tool_spec["name"] | |
| if tool_name.startswith(("download_", "get_", "list_", "search_")): | |
| filtered_tools.append(tool) | |
| return github_mcp_client, filtered_tools | |
| return github_mcp_client, all_github_tools |
Then in worker_agent.py, I consumed these tuples:
| # Build GitHub MCP client with only read-only tools | |
| github_mcp_client, github_tools = build_github_mcp_client( | |
| secrets_json["GITHUB_TOKEN"], "read_only" | |
| ) | |
| opened_clients["GitHub"] = github_mcp_client | |
| # Prefix tool names | |
| github_tools = add_prefix_to_mcp_tools(github_tools, "github") | |
| # Extend tools list | |
| tools.extend(github_tools) |
This pattern repeated six times for six MCP servers.
The problems: I’m calling __enter__() to manually start clients, but Agent expects unstarted clients and calls load_tools() itself. I’m reaching into MCPClient internals with list_tools_sync(). Every builder function returns a tuple that needs unpacking, prefixing, and extending. When Strands changes MCPClient behavior, this breaks.
The New Way: What Strands Added and What I Deleted
Strands PR #895 added three things to MCPClient: a prefix parameter, a tool_filters parameter, and ToolProvider interface support. Each addition let me delete chunks of custom code.
Prefix Parameter
Strands now adds a prefix to every tool name automatically. Pass prefix=”github” and the client handles the rest.
Before:
| # Wrap every tool manually | |
| github_tools = add_prefix_to_mcp_tools(github_tools, "github") |
After:
| # MCPClient handles it | |
| github_mcp_client = MCPClient( | |
| lambda: streamablehttp_client(...), | |
| prefix="github", | |
| ) |
I deleted the entire PrefixedMCPTool class (25 lines) and the add_prefix_to_mcp_tools() function.
tool_filters Parameter
Strands now filters tools using lambda functions you provide. Want only read-only tools? Pass a filter that checks tool names.
Before:
| # Manually loop and filter | |
| filtered_tools = [] | |
| for tool in all_github_tools: | |
| tool_name = tool.tool_spec["name"] | |
| if tool_name.startswith(("download_", "get_", "list_", "search_")): | |
| filtered_tools.append(tool) |
After:
| # MCPClient filters declaratively | |
| TOOLS_PREFIX = "github" | |
| READ_ONLY_PREFIXES = ["download_", "get_", "list_", "search_"] | |
| github_mcp_client = MCPClient( | |
| lambda: streamablehttp_client(...), | |
| tool_filters={ | |
| "allowed": [lambda tool: tool.tool_name.startswith(tuple( | |
| f"{TOOLS_PREFIX}_{p}" for p in READ_ONLY_PREFIXES | |
| ))] | |
| }, | |
| prefix=TOOLS_PREFIX, | |
| ) |
I deleted the manual filtering loops from six MCP client files.
ToolProvider Interface
MCPClient now implements ToolProvider, which means Agent starts the client automatically when it needs tools. I don’t call __enter__() or list_tools_sync() anymore.
Before:
| # Build client | |
| github_mcp_client = MCPClient(...) | |
| # Manually start it | |
| github_client = github_mcp_client.__enter__() | |
| # Extract tools | |
| all_github_tools = github_client.list_tools_sync() | |
| # Return tuple | |
| return github_mcp_client, all_github_tools |
After:
| # Build client | |
| github_mcp_client = MCPClient(...) | |
| # Return it unstarted | |
| return github_mcp_client |
Then in worker_agent.py, instead of unpacking tuples and extending tool lists, I just append the client:
Before:
| github_mcp_client, github_tools = build_github_mcp_client(...) | |
| github_tools = add_prefix_to_mcp_tools(github_tools, "github") | |
| tools.extend(github_tools) |
After:
| github_mcp_client = build_github_mcp_client(...) | |
| tools.append(github_mcp_client) |
Agent calls load_tools() on the client when it needs to, which starts the connection and extracts tools automatically.
Gotchas
Prefix separator: Strands adds an underscore between prefix and tool name automatically. I initially set TOOLS_PREFIX = “pagerduty_” (with trailing underscore) and got tool names like pagerduty__list_incidents (double underscore). Remove the trailing underscore from your prefix constants.
Filter timing: tool_filters run after the prefix is applied. Check for github_get_user, not get_user.
Summary
In this blog we walked through how to move from my custom-built MCP management interface to the MCP tool management pattern that the Strands maintainers built. It’s both less code for us to manage, as well as quite a bit faster to get all those MCPs built in parallel instead of in sequence.
Remember, every custom abstraction is maintenance debt when the upstream library changes. When the library ships the feature natively, future updates are forward compatible by default.
Always let someone else do the hard work.
Full code here: https://github.com/KyMidd/SlackStrandsAgenticBot
Good luck out there!
kyler


