From 7321f7ba85368314ef2a43a7e4d2e5e97a55eee8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 23:09:11 +0000 Subject: [PATCH 1/3] fix(assets): prevent multi-gateway tool-name collisions across HTTP frameworks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two AgentCore Gateways are attached to one agent, both expose the built-in semantic search tool `x_amz_bedrock_agentcore_search` (and may share target names). Each HTTP framework template wired one MCP client/toolset/server per gateway into a single agent, so identical tool names collided: - Strands: ValueError "Tool name already exists" at agent construction - OpenAIAgents: UserError "Duplicate tool names found across MCP servers" - GoogleADK: last-wins dispatch shadow + duplicate FunctionDeclaration (Gemini 400) - LangChain: silent last-wins — one gateway's tool becomes unreachable Each SDK is de-collided with its own first-class namespacing primitive: - Strands: MCPClient(prefix="") - GoogleADK: MCPToolset(tool_name_prefix="") - LangChain: MultiServerMCPClient(servers, tool_name_prefix=True) + snakeCase server keys - OpenAIAgents: Agent(mcp_config={"include_server_in_tool_names": True}) + AsyncExitStack to connect() all servers (also fixes a pre-existing never-connected bug in the gateway path) Version floors bumped only when a gateway is present (non-gateway projects keep the lower floor): - strands-agents 1.13.0 -> 1.15.0 - langchain-mcp-adapters 0.1.11 -> 0.2.0 - openai-agents 0.4.2 -> 0.16.0 Also fixes a pre-existing bug: googleadk/langchain/openaiagents client.py generated invalid Python (over-indented `else:`) when a gateway used NONE auth, caused by indented Handlebars control tags. Moving the {{#if}}/ {{else}}/{{/if}} tags to column 0 lets Handlebars strip them as standalone lines. Adds a regression suite that renders the real asset files and asserts the per-framework namespacing, conditional version floors, and NONE-auth indentation. Verified end-to-end on a real deploy + invoke with two live gateways (Exa + DeepWiki): both gateways' search tools registered distinctly and tools from each executed; a registry-level counterfactual against the deployed gateways confirms the old templates crash and the fixed ones don't. --- .../assets.snapshot.test.ts.snap | 87 ++++---- .../http/googleadk/base/mcp_client/client.py | 14 +- .../base/mcp_client/client.py | 18 +- .../langchain_langgraph/base/pyproject.toml | 5 +- .../python/http/openaiagents/base/main.py | 28 ++- .../openaiagents/base/mcp_client/client.py | 8 +- .../http/openaiagents/base/pyproject.toml | 4 +- .../http/strands/base/mcp_client/client.py | 6 +- .../python/http/strands/base/pyproject.toml | 4 +- .../__tests__/multi-gateway-collision.test.ts | 197 ++++++++++++++++++ 10 files changed, 301 insertions(+), 70 deletions(-) create mode 100644 src/cli/templates/__tests__/multi-gateway-collision.test.ts diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 34fa1fa72..ab8949486 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -4000,20 +4000,20 @@ def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: {{#each gatewayProviders}} url = os.environ.get("{{envVarName}}") if url: - {{#if (eq authType "AWS_IAM")}} +{{#if (eq authType "AWS_IAM")}} session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) - toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams( + toolsets.append(MCPToolset(tool_name_prefix="{{snakeCase name}}", connection_params=StreamableHTTPConnectionParams( url=url, httpx_client_factory=lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs) ))) - {{else if (eq authType "CUSTOM_JWT")}} +{{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else None - toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url, headers=headers))) - {{else}} - toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url))) - {{/if}} + toolsets.append(MCPToolset(tool_name_prefix="{{snakeCase name}}", connection_params=StreamableHTTPConnectionParams(url=url, headers=headers))) +{{else}} + toolsets.append(MCPToolset(tool_name_prefix="{{snakeCase name}}", connection_params=StreamableHTTPConnectionParams(url=url))) +{{/if}} else: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") {{/each}} @@ -4435,23 +4435,25 @@ def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: {{#each gatewayProviders}} url = os.environ.get("{{envVarName}}") if url: - {{#if (eq authType "AWS_IAM")}} +{{#if (eq authType "AWS_IAM")}} session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) - servers["{{name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} - {{else if (eq authType "CUSTOM_JWT")}} + servers["{{snakeCase name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} +{{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else None - servers["{{name}}"] = {"transport": "streamable_http", "url": url, "headers": headers} - {{else}} - servers["{{name}}"] = {"transport": "streamable_http", "url": url} - {{/if}} + servers["{{snakeCase name}}"] = {"transport": "streamable_http", "url": url, "headers": headers} +{{else}} + servers["{{snakeCase name}}"] = {"transport": "streamable_http", "url": url} +{{/if}} else: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") {{/each}} if not servers: return None - return MultiServerMCPClient(servers) + # tool_name_prefix namespaces each gateway's tools by server key so multiple + # gateways exposing the same tool (e.g. x_amz_bedrock_agentcore_search) don't collide. + return MultiServerMCPClient(servers, tool_name_prefix=True) {{else}} {{#if isVpc}} # VPC mode: external MCP endpoints are not reachable without a NAT gateway. @@ -4629,8 +4631,9 @@ dependencies = [ "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", - "langchain-mcp-adapters >= 0.1.11", - "langchain >= 1.0.3", + {{#if hasGateway}}"langchain-mcp-adapters >= 0.2.0", + {{else}}"langchain-mcp-adapters >= 0.1.11", + {{/if}}"langchain >= 1.0.3", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Bedrock")}} @@ -4747,6 +4750,9 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/ht "{{#if needsOs}} import os {{/if}} +{{#if hasGateway}} +from contextlib import AsyncExitStack +{{/if}} from agents import Agent, Runner, function_tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model @@ -4857,15 +4863,22 @@ async def main(query): try: {{#if hasGateway}} if mcp_servers: - agent = Agent( - name="{{ name }}", - model="gpt-4.1", - instructions=INSTRUCTIONS, - mcp_servers=mcp_servers, - tools=tools - ) - result = await Runner.run(agent, query) - return result + # Connect every gateway server before the run; include_server_in_tool_names + # namespaces each server's tools so multiple gateways exposing the same tool + # (e.g. x_amz_bedrock_agentcore_search) don't collide. + async with AsyncExitStack() as stack: + for server in mcp_servers: + await stack.enter_async_context(server) + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + instructions=INSTRUCTIONS, + mcp_servers=mcp_servers, + tools=tools, + mcp_config={"include_server_in_tool_names": True}, + ) + result = await Runner.run(agent, query) + return result else: agent = Agent( name="{{ name }}", @@ -4965,20 +4978,20 @@ def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: {{#each gatewayProviders}} url = os.environ.get("{{envVarName}}") if url: - {{#if (eq authType "AWS_IAM")}} +{{#if (eq authType "AWS_IAM")}} session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) servers.append(MCPServerStreamableHttp( name="{{name}}", params={"url": url, "httpx_client_factory": lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs)} )) - {{else if (eq authType "CUSTOM_JWT")}} +{{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else {} servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url, "headers": headers})) - {{else}} +{{else}} servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url})) - {{/if}} +{{/if}} else: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") {{/each}} @@ -5066,7 +5079,9 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", - "openai-agents >= 0.4.2", + {{#if hasGateway}}"openai-agents >= 0.16.0", + {{else}}"openai-agents >= 0.4.2", + {{/if}} "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", @@ -5471,13 +5486,13 @@ def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") return None {{#if (eq authType "AWS_IAM")}} - return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")))) + return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}") {{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else {} - return MCPClient(lambda: streamablehttp_client(url, headers=headers)) + return MCPClient(lambda: streamablehttp_client(url, headers=headers), prefix="{{snakeCase name}}") {{else}} - return MCPClient(lambda: streamablehttp_client(url)) + return MCPClient(lambda: streamablehttp_client(url), prefix="{{snakeCase name}}") {{/if}} {{/each}} @@ -5662,7 +5677,9 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", - {{/if}}"strands-agents >= 1.13.0", + {{/if}}{{#if hasGateway}}"strands-agents >= 1.15.0", + {{else}}"strands-agents >= 1.13.0", + {{/if}} {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} ] diff --git a/src/assets/python/http/googleadk/base/mcp_client/client.py b/src/assets/python/http/googleadk/base/mcp_client/client.py index df9e0512f..a70c60084 100644 --- a/src/assets/python/http/googleadk/base/mcp_client/client.py +++ b/src/assets/python/http/googleadk/base/mcp_client/client.py @@ -34,20 +34,20 @@ def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: {{#each gatewayProviders}} url = os.environ.get("{{envVarName}}") if url: - {{#if (eq authType "AWS_IAM")}} +{{#if (eq authType "AWS_IAM")}} session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) - toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams( + toolsets.append(MCPToolset(tool_name_prefix="{{snakeCase name}}", connection_params=StreamableHTTPConnectionParams( url=url, httpx_client_factory=lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs) ))) - {{else if (eq authType "CUSTOM_JWT")}} +{{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else None - toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url, headers=headers))) - {{else}} - toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url))) - {{/if}} + toolsets.append(MCPToolset(tool_name_prefix="{{snakeCase name}}", connection_params=StreamableHTTPConnectionParams(url=url, headers=headers))) +{{else}} + toolsets.append(MCPToolset(tool_name_prefix="{{snakeCase name}}", connection_params=StreamableHTTPConnectionParams(url=url))) +{{/if}} else: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") {{/each}} diff --git a/src/assets/python/http/langchain_langgraph/base/mcp_client/client.py b/src/assets/python/http/langchain_langgraph/base/mcp_client/client.py index 8fbf92da7..1622df876 100644 --- a/src/assets/python/http/langchain_langgraph/base/mcp_client/client.py +++ b/src/assets/python/http/langchain_langgraph/base/mcp_client/client.py @@ -32,23 +32,25 @@ def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: {{#each gatewayProviders}} url = os.environ.get("{{envVarName}}") if url: - {{#if (eq authType "AWS_IAM")}} +{{#if (eq authType "AWS_IAM")}} session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) - servers["{{name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} - {{else if (eq authType "CUSTOM_JWT")}} + servers["{{snakeCase name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} +{{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else None - servers["{{name}}"] = {"transport": "streamable_http", "url": url, "headers": headers} - {{else}} - servers["{{name}}"] = {"transport": "streamable_http", "url": url} - {{/if}} + servers["{{snakeCase name}}"] = {"transport": "streamable_http", "url": url, "headers": headers} +{{else}} + servers["{{snakeCase name}}"] = {"transport": "streamable_http", "url": url} +{{/if}} else: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") {{/each}} if not servers: return None - return MultiServerMCPClient(servers) + # tool_name_prefix namespaces each gateway's tools by server key so multiple + # gateways exposing the same tool (e.g. x_amz_bedrock_agentcore_search) don't collide. + return MultiServerMCPClient(servers, tool_name_prefix=True) {{else}} {{#if isVpc}} # VPC mode: external MCP endpoints are not reachable without a NAT gateway. diff --git a/src/assets/python/http/langchain_langgraph/base/pyproject.toml b/src/assets/python/http/langchain_langgraph/base/pyproject.toml index c1cfaa79f..a1d30aecf 100644 --- a/src/assets/python/http/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/http/langchain_langgraph/base/pyproject.toml @@ -13,8 +13,9 @@ dependencies = [ "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", - "langchain-mcp-adapters >= 0.1.11", - "langchain >= 1.0.3", + {{#if hasGateway}}"langchain-mcp-adapters >= 0.2.0", + {{else}}"langchain-mcp-adapters >= 0.1.11", + {{/if}}"langchain >= 1.0.3", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Bedrock")}} diff --git a/src/assets/python/http/openaiagents/base/main.py b/src/assets/python/http/openaiagents/base/main.py index 7879f1726..772f50d42 100644 --- a/src/assets/python/http/openaiagents/base/main.py +++ b/src/assets/python/http/openaiagents/base/main.py @@ -1,6 +1,9 @@ {{#if needsOs}} import os {{/if}} +{{#if hasGateway}} +from contextlib import AsyncExitStack +{{/if}} from agents import Agent, Runner, function_tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model @@ -111,15 +114,22 @@ async def main(query): try: {{#if hasGateway}} if mcp_servers: - agent = Agent( - name="{{ name }}", - model="gpt-4.1", - instructions=INSTRUCTIONS, - mcp_servers=mcp_servers, - tools=tools - ) - result = await Runner.run(agent, query) - return result + # Connect every gateway server before the run; include_server_in_tool_names + # namespaces each server's tools so multiple gateways exposing the same tool + # (e.g. x_amz_bedrock_agentcore_search) don't collide. + async with AsyncExitStack() as stack: + for server in mcp_servers: + await stack.enter_async_context(server) + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + instructions=INSTRUCTIONS, + mcp_servers=mcp_servers, + tools=tools, + mcp_config={"include_server_in_tool_names": True}, + ) + result = await Runner.run(agent, query) + return result else: agent = Agent( name="{{ name }}", diff --git a/src/assets/python/http/openaiagents/base/mcp_client/client.py b/src/assets/python/http/openaiagents/base/mcp_client/client.py index 901fe5fa0..e36b60924 100644 --- a/src/assets/python/http/openaiagents/base/mcp_client/client.py +++ b/src/assets/python/http/openaiagents/base/mcp_client/client.py @@ -33,20 +33,20 @@ def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: {{#each gatewayProviders}} url = os.environ.get("{{envVarName}}") if url: - {{#if (eq authType "AWS_IAM")}} +{{#if (eq authType "AWS_IAM")}} session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) servers.append(MCPServerStreamableHttp( name="{{name}}", params={"url": url, "httpx_client_factory": lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs)} )) - {{else if (eq authType "CUSTOM_JWT")}} +{{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else {} servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url, "headers": headers})) - {{else}} +{{else}} servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url})) - {{/if}} +{{/if}} else: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") {{/each}} diff --git a/src/assets/python/http/openaiagents/base/pyproject.toml b/src/assets/python/http/openaiagents/base/pyproject.toml index a344eccc7..207622681 100644 --- a/src/assets/python/http/openaiagents/base/pyproject.toml +++ b/src/assets/python/http/openaiagents/base/pyproject.toml @@ -10,7 +10,9 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", - "openai-agents >= 0.4.2", + {{#if hasGateway}}"openai-agents >= 0.16.0", + {{else}}"openai-agents >= 0.4.2", + {{/if}} "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", diff --git a/src/assets/python/http/strands/base/mcp_client/client.py b/src/assets/python/http/strands/base/mcp_client/client.py index 981c806a7..13dad314c 100644 --- a/src/assets/python/http/strands/base/mcp_client/client.py +++ b/src/assets/python/http/strands/base/mcp_client/client.py @@ -34,13 +34,13 @@ def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") return None {{#if (eq authType "AWS_IAM")}} - return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")))) + return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}") {{else if (eq authType "CUSTOM_JWT")}} token = _get_bearer_token_{{snakeCase name}}() headers = {"Authorization": f"Bearer {token}"} if token else {} - return MCPClient(lambda: streamablehttp_client(url, headers=headers)) + return MCPClient(lambda: streamablehttp_client(url, headers=headers), prefix="{{snakeCase name}}") {{else}} - return MCPClient(lambda: streamablehttp_client(url)) + return MCPClient(lambda: streamablehttp_client(url), prefix="{{snakeCase name}}") {{/if}} {{/each}} diff --git a/src/assets/python/http/strands/base/pyproject.toml b/src/assets/python/http/strands/base/pyproject.toml index 9f548f6fd..2a9a53017 100644 --- a/src/assets/python/http/strands/base/pyproject.toml +++ b/src/assets/python/http/strands/base/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", - {{/if}}"strands-agents >= 1.13.0", + {{/if}}{{#if hasGateway}}"strands-agents >= 1.15.0", + {{else}}"strands-agents >= 1.13.0", + {{/if}} {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} ] diff --git a/src/cli/templates/__tests__/multi-gateway-collision.test.ts b/src/cli/templates/__tests__/multi-gateway-collision.test.ts new file mode 100644 index 000000000..d13250c0b --- /dev/null +++ b/src/cli/templates/__tests__/multi-gateway-collision.test.ts @@ -0,0 +1,197 @@ +import { copyAndRenderDir } from '../render.js'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +/** + * Regression tests for the multi-gateway tool-name collision. + * + * Two AgentCore Gateways each expose the built-in semantic search tool + * `x_amz_bedrock_agentcore_search` (and may share target names). Each HTTP + * framework template wires one MCP client/toolset/server per gateway into a + * single agent, so identical tool names collide. Each SDK is de-collided with + * its own namespacing primitive: + * - Strands: MCPClient(prefix=...) + * - Google ADK: MCPToolset(tool_name_prefix=...) + * - LangChain: MultiServerMCPClient(servers, tool_name_prefix=True) (prefixes by server key) + * - OpenAIAgents: Agent(mcp_config={"include_server_in_tool_names": True}) + * + * Gateway names are constrained to /^[a-zA-Z][a-zA-Z0-9-]*$/, so `snakeCase` + * always yields a valid, unique tool-name prefix. + */ + +const ASSETS_HTTP = join(__dirname, '../../../assets/python/http'); + +const TWO_IAM_GATEWAYS = { + hasGateway: true, + gatewayAuthTypes: ['AWS_IAM'], + gatewayProviders: [ + { name: 'orders-gw', envVarName: 'ORDERS_GW_GATEWAY_URL', authType: 'AWS_IAM' }, + { name: 'inventory', envVarName: 'INVENTORY_GATEWAY_URL', authType: 'AWS_IAM' }, + ], +}; + +const JWT_AND_NONE_GATEWAYS = { + hasGateway: true, + gatewayAuthTypes: ['CUSTOM_JWT', 'NONE'], + gatewayProviders: [ + { + name: 'orders-gw', + envVarName: 'ORDERS_GW_GATEWAY_URL', + authType: 'CUSTOM_JWT', + credentialProviderName: 'orders-oauth', + }, + { name: 'public', envVarName: 'PUBLIC_GATEWAY_URL', authType: 'NONE' }, + ], +}; + +describe('Multi-gateway tool-name collision fix', () => { + let srcDir: string; + let destDir: string; + + beforeEach(() => { + srcDir = mkdtempSync(join(tmpdir(), 'mg-src-')); + destDir = join(mkdtempSync(join(tmpdir(), 'mg-dest-')), 'output'); + }); + + afterEach(() => { + rmSync(srcDir, { recursive: true, force: true }); + rmSync(join(destDir, '..'), { recursive: true, force: true }); + }); + + /** Renders a single template asset file through the project's Handlebars pipeline. */ + async function renderAsset(relPath: string, data: object): Promise { + const dir = join(srcDir, 'tpl'); + mkdirSync(dir, { recursive: true }); + const fileName = relPath.split('/').pop()!; + cpSync(join(ASSETS_HTTP, relPath), join(dir, fileName)); + await copyAndRenderDir(dir, destDir, data); + return readFileSync(join(destDir, fileName), 'utf-8'); + } + + describe('Strands', () => { + it('gives each gateway MCPClient a distinct snakeCase prefix', async () => { + const out = await renderAsset('strands/base/mcp_client/client.py', TWO_IAM_GATEWAYS); + expect(out).toContain('prefix="orders_gw"'); + expect(out).toContain('prefix="inventory"'); + }); + + it('prefixes across CUSTOM_JWT and NONE auth types', async () => { + const out = await renderAsset('strands/base/mcp_client/client.py', JWT_AND_NONE_GATEWAYS); + expect(out).toContain('prefix="orders_gw"'); + expect(out).toContain('prefix="public"'); + }); + + it('bumps the strands-agents floor to >= 1.15.0 only when a gateway exists', async () => { + const withGw = await renderAsset('strands/base/pyproject.toml', { + ...TWO_IAM_GATEWAYS, + name: 'a', + modelProvider: 'Bedrock', + }); + const noGw = await renderAsset('strands/base/pyproject.toml', { + hasGateway: false, + name: 'a', + modelProvider: 'Bedrock', + }); + expect(withGw).toContain('strands-agents >= 1.15.0'); + expect(noGw).toContain('strands-agents >= 1.13.0'); + }); + }); + + describe('Google ADK', () => { + it('gives each gateway MCPToolset a distinct tool_name_prefix', async () => { + const out = await renderAsset('googleadk/base/mcp_client/client.py', TWO_IAM_GATEWAYS); + expect(out).toContain('tool_name_prefix="orders_gw"'); + expect(out).toContain('tool_name_prefix="inventory"'); + }); + + it('prefixes across CUSTOM_JWT and NONE auth types', async () => { + const out = await renderAsset('googleadk/base/mcp_client/client.py', JWT_AND_NONE_GATEWAYS); + expect(out).toContain('tool_name_prefix="orders_gw"'); + expect(out).toContain('tool_name_prefix="public"'); + }); + }); + + describe('LangChain / LangGraph', () => { + it('namespaces by snakeCase server key and enables tool_name_prefix', async () => { + const out = await renderAsset('langchain_langgraph/base/mcp_client/client.py', TWO_IAM_GATEWAYS); + expect(out).toContain('servers["orders_gw"]'); + expect(out).toContain('servers["inventory"]'); + expect(out).toContain('MultiServerMCPClient(servers, tool_name_prefix=True)'); + }); + + it('bumps langchain-mcp-adapters floor to >= 0.2.0 only when a gateway exists', async () => { + const withGw = await renderAsset('langchain_langgraph/base/pyproject.toml', { + ...TWO_IAM_GATEWAYS, + modelProvider: 'Bedrock', + }); + const noGw = await renderAsset('langchain_langgraph/base/pyproject.toml', { + hasGateway: false, + modelProvider: 'Bedrock', + }); + expect(withGw).toContain('langchain-mcp-adapters >= 0.2.0'); + expect(noGw).toContain('langchain-mcp-adapters >= 0.1.11'); + }); + }); + + describe('OpenAI Agents', () => { + it('enables include_server_in_tool_names and connects servers via AsyncExitStack', async () => { + const out = await renderAsset('openaiagents/base/main.py', TWO_IAM_GATEWAYS); + expect(out).toContain('"include_server_in_tool_names": True'); + expect(out).toContain('AsyncExitStack()'); + expect(out).toContain('await stack.enter_async_context(server)'); + }); + + it('does not import AsyncExitStack when there is no gateway', async () => { + const out = await renderAsset('openaiagents/base/main.py', { hasGateway: false }); + expect(out).not.toContain('AsyncExitStack'); + }); + + it('bumps the openai-agents floor to >= 0.16.0 only when a gateway exists', async () => { + const withGw = await renderAsset('openaiagents/base/pyproject.toml', { ...TWO_IAM_GATEWAYS, name: 'a' }); + const noGw = await renderAsset('openaiagents/base/pyproject.toml', { hasGateway: false, name: 'a' }); + expect(withGw).toContain('openai-agents >= 0.16.0'); + expect(noGw).toContain('openai-agents >= 0.4.2'); + }); + }); + + /** + * The auth-type {{#if}}/{{else}}/{{/if}} block sits inside an `if url:` immediately + * followed by `else:`. When the control tags were indented, Handlebars left stray + * whitespace that over-indented the Python `else:` (` else:`), producing + * a SyntaxError — but only on the NONE-auth branch. Control tags now sit at column 0 + * so Handlebars removes them as standalone lines, keeping `else:` at 4-space indent. + */ + describe('NONE-auth renders valid Python indentation', () => { + const NONE_GATEWAYS = { + hasGateway: true, + gatewayAuthTypes: ['NONE'], + gatewayProviders: [ + { name: 'public-a', envVarName: 'PUBLIC_A_GATEWAY_URL', authType: 'NONE' }, + { name: 'public-b', envVarName: 'PUBLIC_B_GATEWAY_URL', authType: 'NONE' }, + ], + }; + + it.each([ + ['Google ADK', 'googleadk/base/mcp_client/client.py'], + ['LangChain', 'langchain_langgraph/base/mcp_client/client.py'], + ['OpenAIAgents', 'openaiagents/base/mcp_client/client.py'], + ])('%s keeps the else: at 4-space indent (not over-indented)', async (_fw, asset) => { + const out = await renderAsset(asset, NONE_GATEWAYS); + expect(out).toContain('\n else:\n'); + expect(out).not.toContain(' else:'); + }); + + // Strands uses a different `if not url: return None` structure with no trailing + // Python `else:`, so it never had the over-indent bug and was intentionally not + // touched. Assert it still renders the prefixed clients cleanly for NONE auth so a + // future Strands template edit can't silently reintroduce the collision/indent bug. + it('Strands renders prefixed NONE-auth clients without an over-indented else', async () => { + const out = await renderAsset('strands/base/mcp_client/client.py', NONE_GATEWAYS); + expect(out).toContain('prefix="public_a"'); + expect(out).toContain('prefix="public_b"'); + expect(out).not.toContain(' else:'); + }); + }); +}); From 23c76f7cf9e5b53d7d58255b56d54419684ce958 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 23:45:00 +0000 Subject: [PATCH 2/3] chore(assets): bump google-adk floor to >= 1.35.0 (keep < 2.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: align GoogleADK with the other frameworks' dependency bumps in this PR. tool_name_prefix is already available at the prior 1.17.0 floor, so this isn't required by the multi-gateway fix — it's an opportunistic baseline bump for newly created ADK projects. Verified tool_name_prefix is present in 1.35.0; the < 2.0.0 ceiling is retained (ADK 2.x is an unvalidated major). Adds a floor assertion to the regression suite and regenerates the asset snapshot. --- .../__tests__/__snapshots__/assets.snapshot.test.ts.snap | 2 +- src/assets/python/http/googleadk/base/pyproject.toml | 2 +- src/cli/templates/__tests__/multi-gateway-collision.test.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index ab8949486..70384baea 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -4106,7 +4106,7 @@ requires-python = ">=3.10" dependencies = [ "opentelemetry-distro", "opentelemetry-exporter-otlp", - "google-adk >= 1.17.0, < 2.0.0", + "google-adk >= 1.35.0, < 2.0.0", "google-genai >= 1.0.0, < 2.0.0", "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", diff --git a/src/assets/python/http/googleadk/base/pyproject.toml b/src/assets/python/http/googleadk/base/pyproject.toml index f37841b05..b37575da0 100644 --- a/src/assets/python/http/googleadk/base/pyproject.toml +++ b/src/assets/python/http/googleadk/base/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" dependencies = [ "opentelemetry-distro", "opentelemetry-exporter-otlp", - "google-adk >= 1.17.0, < 2.0.0", + "google-adk >= 1.35.0, < 2.0.0", "google-genai >= 1.0.0, < 2.0.0", "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", diff --git a/src/cli/templates/__tests__/multi-gateway-collision.test.ts b/src/cli/templates/__tests__/multi-gateway-collision.test.ts index d13250c0b..8995f8f42 100644 --- a/src/cli/templates/__tests__/multi-gateway-collision.test.ts +++ b/src/cli/templates/__tests__/multi-gateway-collision.test.ts @@ -111,6 +111,11 @@ describe('Multi-gateway tool-name collision fix', () => { expect(out).toContain('tool_name_prefix="orders_gw"'); expect(out).toContain('tool_name_prefix="public"'); }); + + it('pins google-adk to a tool_name_prefix-capable floor under the 2.x ceiling', async () => { + const out = await renderAsset('googleadk/base/pyproject.toml', { ...TWO_IAM_GATEWAYS, name: 'a' }); + expect(out).toContain('google-adk >= 1.35.0, < 2.0.0'); + }); }); describe('LangChain / LangGraph', () => { From 2f9caed9fe655c725ad93c7683babb4258c141cb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 23:54:02 +0000 Subject: [PATCH 3/3] refactor(assets): pin gateway-capable SDK floors unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: drop the {{#if hasGateway}} conditional on the SDK version floors so every generated agent pins the gateway-capable version regardless of whether a gateway exists at create time: - strands-agents always >= 1.15.0 (was 1.13.0 without gateway) - langchain-mcp-adapters always >= 0.2.0 (was 0.1.11 without gateway) - openai-agents always >= 0.16.0 (was 0.4.2 without gateway) For a vended template the floor only affects freshly created projects, so the cost of a slightly newer baseline for non-gateway agents is negligible, and it removes three Handlebars branches. Note this does not by itself make "add gateway after agent" work — that still requires regenerating the agent's client.py (add gateway does not re-render agent code today); this is purely a template simplification. Updates the floor assertions and regenerates the asset snapshot. --- .../__snapshots__/assets.snapshot.test.ts.snap | 13 ++++--------- .../http/langchain_langgraph/base/pyproject.toml | 5 ++--- .../python/http/openaiagents/base/pyproject.toml | 4 +--- .../python/http/strands/base/pyproject.toml | 4 +--- .../__tests__/multi-gateway-collision.test.ts | 15 +++++++++------ 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 70384baea..493893070 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -4631,9 +4631,8 @@ dependencies = [ "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", - {{#if hasGateway}}"langchain-mcp-adapters >= 0.2.0", - {{else}}"langchain-mcp-adapters >= 0.1.11", - {{/if}}"langchain >= 1.0.3", + "langchain-mcp-adapters >= 0.2.0", + "langchain >= 1.0.3", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Bedrock")}} @@ -5079,9 +5078,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", - {{#if hasGateway}}"openai-agents >= 0.16.0", - {{else}}"openai-agents >= 0.4.2", - {{/if}} + "openai-agents >= 0.16.0", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", @@ -5677,9 +5674,7 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", - {{/if}}{{#if hasGateway}}"strands-agents >= 1.15.0", - {{else}}"strands-agents >= 1.13.0", - {{/if}} + {{/if}}"strands-agents >= 1.15.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} ] diff --git a/src/assets/python/http/langchain_langgraph/base/pyproject.toml b/src/assets/python/http/langchain_langgraph/base/pyproject.toml index a1d30aecf..4be46c458 100644 --- a/src/assets/python/http/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/http/langchain_langgraph/base/pyproject.toml @@ -13,9 +13,8 @@ dependencies = [ "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", - {{#if hasGateway}}"langchain-mcp-adapters >= 0.2.0", - {{else}}"langchain-mcp-adapters >= 0.1.11", - {{/if}}"langchain >= 1.0.3", + "langchain-mcp-adapters >= 0.2.0", + "langchain >= 1.0.3", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Bedrock")}} diff --git a/src/assets/python/http/openaiagents/base/pyproject.toml b/src/assets/python/http/openaiagents/base/pyproject.toml index 207622681..2f073c829 100644 --- a/src/assets/python/http/openaiagents/base/pyproject.toml +++ b/src/assets/python/http/openaiagents/base/pyproject.toml @@ -10,9 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", - {{#if hasGateway}}"openai-agents >= 0.16.0", - {{else}}"openai-agents >= 0.4.2", - {{/if}} + "openai-agents >= 0.16.0", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", diff --git a/src/assets/python/http/strands/base/pyproject.toml b/src/assets/python/http/strands/base/pyproject.toml index 2a9a53017..3722d0ea9 100644 --- a/src/assets/python/http/strands/base/pyproject.toml +++ b/src/assets/python/http/strands/base/pyproject.toml @@ -16,9 +16,7 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", - {{/if}}{{#if hasGateway}}"strands-agents >= 1.15.0", - {{else}}"strands-agents >= 1.13.0", - {{/if}} + {{/if}}"strands-agents >= 1.15.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} ] diff --git a/src/cli/templates/__tests__/multi-gateway-collision.test.ts b/src/cli/templates/__tests__/multi-gateway-collision.test.ts index 8995f8f42..45ef08027 100644 --- a/src/cli/templates/__tests__/multi-gateway-collision.test.ts +++ b/src/cli/templates/__tests__/multi-gateway-collision.test.ts @@ -83,7 +83,7 @@ describe('Multi-gateway tool-name collision fix', () => { expect(out).toContain('prefix="public"'); }); - it('bumps the strands-agents floor to >= 1.15.0 only when a gateway exists', async () => { + it('pins the gateway-capable strands-agents floor unconditionally', async () => { const withGw = await renderAsset('strands/base/pyproject.toml', { ...TWO_IAM_GATEWAYS, name: 'a', @@ -95,7 +95,8 @@ describe('Multi-gateway tool-name collision fix', () => { modelProvider: 'Bedrock', }); expect(withGw).toContain('strands-agents >= 1.15.0'); - expect(noGw).toContain('strands-agents >= 1.13.0'); + expect(noGw).toContain('strands-agents >= 1.15.0'); + expect(noGw).not.toContain('1.13.0'); }); }); @@ -126,7 +127,7 @@ describe('Multi-gateway tool-name collision fix', () => { expect(out).toContain('MultiServerMCPClient(servers, tool_name_prefix=True)'); }); - it('bumps langchain-mcp-adapters floor to >= 0.2.0 only when a gateway exists', async () => { + it('pins the gateway-capable langchain-mcp-adapters floor unconditionally', async () => { const withGw = await renderAsset('langchain_langgraph/base/pyproject.toml', { ...TWO_IAM_GATEWAYS, modelProvider: 'Bedrock', @@ -136,7 +137,8 @@ describe('Multi-gateway tool-name collision fix', () => { modelProvider: 'Bedrock', }); expect(withGw).toContain('langchain-mcp-adapters >= 0.2.0'); - expect(noGw).toContain('langchain-mcp-adapters >= 0.1.11'); + expect(noGw).toContain('langchain-mcp-adapters >= 0.2.0'); + expect(noGw).not.toContain('0.1.11'); }); }); @@ -153,11 +155,12 @@ describe('Multi-gateway tool-name collision fix', () => { expect(out).not.toContain('AsyncExitStack'); }); - it('bumps the openai-agents floor to >= 0.16.0 only when a gateway exists', async () => { + it('pins the gateway-capable openai-agents floor unconditionally', async () => { const withGw = await renderAsset('openaiagents/base/pyproject.toml', { ...TWO_IAM_GATEWAYS, name: 'a' }); const noGw = await renderAsset('openaiagents/base/pyproject.toml', { hasGateway: false, name: 'a' }); expect(withGw).toContain('openai-agents >= 0.16.0'); - expect(noGw).toContain('openai-agents >= 0.4.2'); + expect(noGw).toContain('openai-agents >= 0.16.0'); + expect(noGw).not.toContain('0.4.2'); }); });