diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 34fa1fa72..493893070 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}} @@ -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", @@ -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,7 +4631,7 @@ dependencies = [ "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", - "langchain-mcp-adapters >= 0.1.11", + "langchain-mcp-adapters >= 0.2.0", "langchain >= 1.0.3", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", @@ -4747,6 +4749,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 +4862,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 +4977,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 +5078,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", - "openai-agents >= 0.4.2", + "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", @@ -5471,13 +5483,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 +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}}"strands-agents >= 1.13.0", + {{/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/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/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/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..4be46c458 100644 --- a/src/assets/python/http/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/http/langchain_langgraph/base/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", - "langchain-mcp-adapters >= 0.1.11", + "langchain-mcp-adapters >= 0.2.0", "langchain >= 1.0.3", "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", 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..2f073c829 100644 --- a/src/assets/python/http/openaiagents/base/pyproject.toml +++ b/src/assets/python/http/openaiagents/base/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", - "openai-agents >= 0.4.2", + "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/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..3722d0ea9 100644 --- a/src/assets/python/http/strands/base/pyproject.toml +++ b/src/assets/python/http/strands/base/pyproject.toml @@ -16,7 +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}}"strands-agents >= 1.13.0", + {{/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 new file mode 100644 index 000000000..45ef08027 --- /dev/null +++ b/src/cli/templates/__tests__/multi-gateway-collision.test.ts @@ -0,0 +1,205 @@ +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('pins the gateway-capable strands-agents floor unconditionally', 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.15.0'); + expect(noGw).not.toContain('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"'); + }); + + 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', () => { + 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('pins the gateway-capable langchain-mcp-adapters floor unconditionally', 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.2.0'); + expect(noGw).not.toContain('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('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.16.0'); + expect(noGw).not.toContain('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:'); + }); + }); +});