Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions src/assets/templates/export-harness-python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,13 @@
{{/if}}
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from model.load import load_model
{{#if remoteMcpTools}}
from mcp_client.client import get_all_remote_mcp_clients
{{/if}}
{{#unless remoteMcpTools}}
{{#if (or gatewayTools remoteMcpTools)}}
from mcp_client.client import get_all_mcp_clients
{{else}}
{{#unless isExportHarness}}
from mcp_client.client import get_streamable_http_mcp_client
{{/unless}}
{{/unless}}
{{/if}}
{{#if hasMemory}}
from memory.session import get_memory_session_manager
{{/if}}
Expand All @@ -58,12 +57,9 @@
app = BedrockAgentCoreApp()
log = app.logger

{{#if remoteMcpTools}}
{{#if (or gatewayTools remoteMcpTools)}}
# Define MCP clients for all configured MCP servers (gateways and/or remote MCP)
mcp_clients = []
{{#if remoteMcpTools}}
mcp_clients += get_all_remote_mcp_clients()
{{/if}}
mcp_clients = get_all_mcp_clients()
{{else}}
{{#unless isExportHarness}}
# Define a Streamable HTTP MCP Client
Expand Down Expand Up @@ -279,7 +275,7 @@ def list_files(path: str) -> str:
tools.extend([file_read, file_write, list_files])
{{/unless}}{{/if}}

{{#if remoteMcpTools}}
{{#if (or gatewayTools remoteMcpTools)}}
# Add MCP clients to tools
for mcp_client in mcp_clients:
if mcp_client:
Expand Down
64 changes: 58 additions & 6 deletions src/assets/templates/export-harness-python/mcp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@

logger = logging.getLogger(__name__)

{{#if remoteMcpTools}}
{{#if (some remoteMcpTools "toolPatterns")}}
{{#if (or (some remoteMcpTools "toolPatterns") (some gatewayTools "toolPatterns"))}}
import fnmatch


Expand All @@ -18,6 +17,50 @@ def allowed(tool, **_):
return {"allowed": [allowed]}

{{/if}}
{{#if (includes gatewayAuthTypes "AWS_IAM")}}
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client

{{/if}}
{{#if (includes gatewayAuthTypes "OAUTH")}}
from bedrock_agentcore.identity.auth import requires_access_token

{{/if}}
{{#each gatewayTools}}
{{#if oauth}}
@requires_access_token(
provider_name={{safeJson oauth.providerName}},
scopes={{safeJson oauth.scopes}},
auth_flow="M2M",
{{#if oauth.customParameters}}
custom_parameters={{safeJson oauth.customParameters}},
{{/if}}
)
def _get_{{pythonName}}_token(*, access_token: str) -> str:
"""Fetch an OAuth access token for the {{name}} gateway from AgentCore Identity."""
return access_token

{{/if}}
def get_{{pythonName}}_mcp_client() -> MCPClient | None:
"""Returns an MCP Client for the {{name}} AgentCore Gateway."""
url = os.environ.get("{{urlEnvVarName}}")
if not url:
logger.warning("{{urlEnvVarName}} is not set; {{name}} gateway tools are unavailable")
return None
def transport():
{{#if (eq authType "AWS_IAM")}}
return aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")))
{{/if}}
{{#if oauth}}
return streamablehttp_client(url, headers={"Authorization": f"Bearer {_get_{{pythonName}}_token()}"})
{{/if}}
{{#if (eq authType "NONE")}}
return streamablehttp_client(url)
{{/if}}

return MCPClient(transport{{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}})

{{/each}}
{{#if remoteMcpTools}}
{{#if (some remoteMcpTools "headerCredentials")}}
from bedrock_agentcore.identity.auth import requires_api_key
{{/if}}
Expand Down Expand Up @@ -48,12 +91,21 @@ def transport():
{{/if}}

{{/each}}
def get_all_remote_mcp_clients() -> list[MCPClient]:
"""Returns all configured remote MCP clients."""
clients = [{{#each remoteMcpTools}}get_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}]
{{/if}}
{{#if (or gatewayTools remoteMcpTools)}}
def get_all_mcp_clients() -> list[MCPClient]:
"""Returns all configured gateway and remote MCP clients."""
clients = [
{{#each gatewayTools}}
get_{{pythonName}}_mcp_client(),
{{/each}}
{{#each remoteMcpTools}}
get_{{pythonName}}_mcp_client(),
{{/each}}
]
return [c for c in clients if c is not None]
{{/if}}
{{#unless remoteMcpTools}}
{{#unless (or gatewayTools remoteMcpTools)}}
{{#if isVpc}}
# VPC mode: external MCP endpoints are not reachable without a NAT gateway.
# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below.
Expand Down
3 changes: 2 additions & 1 deletion src/assets/templates/export-harness-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ dependencies = [
"bedrock-agentcore[strands-agents] >= 1.18.1, < 2.0.0",
"botocore[crt] ~= 1.43.0",
"mcp >= 1.28.1, < 2.0.0",
{{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0",
{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0, < 2.0.0",
{{/if}} {{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0",
{{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} >= 1.62.0, < 2.0.0",

]
Expand Down
58 changes: 58 additions & 0 deletions src/core/project/manager.export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,64 @@ describe("FsProjectManager.exportHarness rendered tree", () => {
expect(client).toContain('tool_filters=_allowed_tools("exa", "web_*")');
});

test("connects gateway tools through their URL variable and records the connections", async () => {
const { manager: subject } = manager();
const gatewayArn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/example-abc123";
const project = await projectWithHarness(subject, {
allowedTools: ["@tools/*", "@partner"],
tools: [
{
type: "agentcore_gateway",
name: "tools",
config: { agentCoreGateway: { gatewayArn, outboundAuth: { awsIam: {} } } },
},
{
type: "agentcore_gateway",
name: "partner",
config: {
agentCoreGateway: {
gatewayArn,
outboundAuth: {
oauth: {
providerArn:
"arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/partner-oauth",
scopes: ["partner/invoke"],
},
},
},
},
},
],
});

const result = await drain(subject.exportHarness(project, exportInput()));

const client = await Bun.file(join(result.agentPath, "mcp_client", "client.py")).text();
expect(client).toContain('url = os.environ.get("GATEWAY_TOOLS_URL")');
expect(client).toContain('aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore"');
expect(client).toContain('provider_name="partner-oauth"');
expect(client).toMatch(
/headers=\{"Authorization": f"Bearer \{_get_partner_[a-f0-9]{10}_token\(\)\}"\}/,
);
const main = await Bun.file(join(result.agentPath, "main.py")).text();
expect(main).toContain("mcp_clients = get_all_mcp_clients()");
const pyproject = await Bun.file(join(result.agentPath, "pyproject.toml")).text();
expect(pyproject).toContain('"mcp-proxy-for-aws >= 1.1.0, < 2.0.0"');
const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json();
expect(
spec.runtimes.find((runtime: { name: string }) => runtime.name === "assistantAgent")
.connections,
).toEqual([
{ id: "tools", to: { type: "gateway", arn: gatewayArn, outboundAuth: { awsIam: {} } } },
expect.objectContaining({ id: "partner" }),
]);
expect(
(await readEnvFile(join(project.rootPath, "agentcore", ".env.local"))).GATEWAY_TOOLS_URL,
).toBe("https://example-abc123.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp");
const notes = await Bun.file(join(result.agentPath, "EXPORT_NOTES.md")).text();
expect(notes).not.toContain("Gateway tool not exported");
});

test("merges service model parameters under the explicit settings", async () => {
const { manager: subject } = manager();
const project = await projectWithHarness(subject);
Expand Down
154 changes: 142 additions & 12 deletions src/core/project/templates/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,15 +396,6 @@ describe("mapHarnessToExportPlan tools", () => {
const result = plan({
spec: harness({
tools: [
{
type: "agentcore_gateway",
name: "gw",
config: {
agentCoreGateway: {
gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:111122223333:gateway/g-1",
},
},
},
{ type: "agentcore_browser", name: "browser" },
{ type: "agentcore_code_interpreter", name: "ci" },
],
Expand All @@ -415,14 +406,11 @@ describe("mapHarnessToExportPlan tools", () => {
// branch to render them from — the notes below are the whole output.
expect(result.context.hasBrowser).toBeUndefined();
expect(result.context.hasCodeInterpreter).toBeUndefined();
expect(result.context.hasGateway).toBeUndefined();
expect(result.context.remoteMcpTools).toBeUndefined();
expect(categories(result)).toEqual([
GATEWAY_TOOL_NOTE_CATEGORY,
BROWSER_TOOL_NOTE_CATEGORY,
CODE_INTERPRETER_TOOL_NOTE_CATEGORY,
]);
expect(result.notes[0]!.message).toContain("gateway/g-1");
});

test("includes the harness builtins unless allowedTools filters them out", () => {
Expand Down Expand Up @@ -461,6 +449,148 @@ describe("mapHarnessToExportPlan tools", () => {
});
});

describe("mapHarnessToExportPlan gateway tools", () => {
const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/example-abc123";
const PROVIDER_ARN =
"arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/gw-oauth";
const MEMORY_CONNECTION = {
id: "notes",
to: {
type: "memory",
arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/notes-AbCdEf1234",
},
} as const;
const gatewayTool = (name: string, outboundAuth?: Record<string, unknown>) => ({
type: "agentcore_gateway",
name,
config: {
agentCoreGateway: { gatewayArn: GATEWAY_ARN, ...(outboundAuth && { outboundAuth }) },
},
});
const oauth = {
providerArn: PROVIDER_ARN,
scopes: ["tools/invoke"],
grantType: "CLIENT_CREDENTIALS" as const,
customParameters: { audience: "tools" },
};

test.each([
["no outboundAuth", undefined, "AWS_IAM", undefined, { awsIam: {} }],
["awsIam", { awsIam: {} }, "AWS_IAM", undefined, { awsIam: {} }],
["none", { none: {} }, "NONE", undefined, { none: {} }],
[
"oauth client credentials",
{ oauth },
"OAUTH",
{
providerName: "gw-oauth",
scopes: ["tools/invoke"],
customParameters: { audience: "tools" },
},
{ oauth },
],
])(
"exports a gateway tool with %s as a client and a runtime connection",
(_, outboundAuth, authType, oauthContext, connectionAuth) => {
const result = plan({
spec: harness({
tools: [gatewayTool("my-tools", outboundAuth)],
connections: [MEMORY_CONNECTION],
}),
});

expect(result.context.gatewayTools).toEqual([
{
name: "my-tools",
pythonName: expect.stringMatching(/^my_tools_[a-f0-9]{10}$/),
urlEnvVarName: "GATEWAY_MY_TOOLS_URL",
authType,
oauth: oauthContext,
toolPatterns: undefined,
},
]);
expect(result.context.gatewayAuthTypes).toEqual([authType]);
expect(result.runtime.connections).toEqual([
MEMORY_CONNECTION,
{ id: "my-tools", to: { type: "gateway", arn: GATEWAY_ARN, outboundAuth: connectionAuth } },
]);
expect(result.envEntries).toEqual([
{
key: "GATEWAY_MY_TOOLS_URL",
value: "https://example-abc123.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp",
comment: expect.stringContaining('gateway tool "my-tools"'),
},
]);
expect(result.notes).toEqual([]);
},
);

test("narrows a gateway to the tools @server/tool selects and drops an unselected one", () => {
const result = plan({
spec: harness({
tools: [gatewayTool("tools"), gatewayTool("other")],
allowedTools: ["@tools/search*"],
}),
});

expect(result.context.gatewayTools).toEqual([
expect.objectContaining({ name: "tools", toolPatterns: ["search*"] }),
]);
expect(result.runtime.connections?.map((connection) => connection.id)).toEqual(["tools"]);
});

test("reports a USER_FEDERATION gateway tool instead of exporting it", () => {
const result = plan({
spec: harness({
tools: [gatewayTool("tools", { oauth: { ...oauth, grantType: "USER_FEDERATION" } })],
}),
});

expect(result.context.gatewayTools).toBeUndefined();
expect(result.runtime.connections).toBeUndefined();
expect(result.envEntries).toEqual([]);
expect(categories(result)).toEqual([GATEWAY_TOOL_NOTE_CATEGORY]);
expect(result.notes[0]!.message).toContain(GATEWAY_ARN);
});

test.each([
[
"a name that cannot be a connection id",
[gatewayTool("1tools")],
[],
/must start with a letter/,
],
[
"names that collide once tokenized",
[gatewayTool("my-tools"), gatewayTool("my_tools")],
[],
/"MY_TOOLS"/,
],
[
"a name that collides with a harness connection",
[gatewayTool("notes")],
[MEMORY_CONNECTION],
/"NOTES"/,
],
[
"a gatewayArn that is not a gateway ARN",
[
{
type: "agentcore_gateway",
name: "tools",
config: { agentCoreGateway: { gatewayArn: "example-abc123" } },
},
],
[],
/not a gateway ARN/,
],
])("rejects a gateway tool with %s", (_, tools, connections, message) => {
const exportPlan = () => plan({ spec: harness({ tools, connections }) });
expect(exportPlan).toThrow(InputValidationError);
expect(exportPlan).toThrow(message);
});
});

describe("mapHarnessToExportPlan allowedTools selection", () => {
test("a bare name or glob selects builtins only", () => {
const shellOnly = plan({ spec: harness({ allowedTools: ["shell"] }) });
Expand Down
Loading
Loading