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
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand Down Expand Up @@ -159,6 +160,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
# Inspect Gateway resources without project configuration or deployment
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand Down Expand Up @@ -212,6 +214,64 @@ Source-aware values: any field flag documented as such accepts the value inline,
`file://` convention). A command reads stdin from at most one flag. For example,
`--instructions file://order-quality.txt` or `--instructions -`.

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
agentcore gateway invoke \
--id <gatewayId> \
--payload '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agentcore-cli","version":"1"}}}' \
--accept 'application/json, text/event-stream' \
--mcp-protocol-version 2025-03-26

# HTTP target: --path is relative to the Gateway origin.
agentcore gateway invoke \
--id <gatewayId> \
--path support-agent/invocations \
--payload file://request.json \
--session-id <runtimeSessionId>

# Inference target.
agentcore gateway invoke \
--id <gatewayId> \
--path inference/v1/messages \
--payload file://message.json \
--json

# GET requests do not accept a payload.
agentcore gateway invoke \
--id <gatewayId> \
--method GET \
--path inference/v1/models
```

`--path` replaces the path in the returned Gateway URL while retaining its
origin. It must remain relative to the selected Gateway and may include a query
string. Omitting it uses the returned `gatewayUrl` exactly. Supported methods
are `GET`, `POST` (the default), and `DELETE`. POST requires `--payload`; DELETE
may include one. Payloads accept inline bytes, `file://<path>`, or `-` for stdin.

Authentication follows `GetGateway.authorizerType`: `AWS_IAM` and
`AUTHENTICATE_ONLY` requests use SigV4, `CUSTOM_JWT` requires `--bearer-token`,
and `NONE` uses unsigned HTTPS. Bearer tokens accept inline, `file://`, or stdin
sources; payload and token cannot both read stdin.

Raw responses stream exact bytes to stdout. `--output-file` streams those bytes
to disk, while `--json` buffers one envelope containing status, selected session
and request metadata, body encoding, and body. Binary or unknown output requires
`--output-file` or `--json` when stdout is a terminal. Response metadata goes to
stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.

### Invoke a Runtime

Headless invocation accepts inline, file, or stdin payload bytes:
Expand Down
11 changes: 8 additions & 3 deletions src/core/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type TargetSummary,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { ResultTruncationError } from "../errors";
import { createSilentLogger } from "../testing";
import type { AwsClients } from "./types";
import { GatewayClient } from "./gateway";

Expand All @@ -23,9 +24,13 @@ function ordinary(targetId: string): TargetSummary {
function gatewayClient(
send: (command: GetGatewayTargetCommand | ListGatewayTargetsCommand) => Promise<unknown>,
): GatewayClient {
return new GatewayClient({
control: () => ({ send: mock(send) }) as never,
} as unknown as AwsClients);
return new GatewayClient(
{
control: () => ({ send: mock(send) }) as never,
} as unknown as AwsClients,
globalThis.fetch,
createSilentLogger(),
);
}

describe("GatewayClient Connector facade", () => {
Expand Down
141 changes: 137 additions & 4 deletions src/core/gateway.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,36 @@ import type {
CreateGatewayInput,
CreateGatewayRuleInput,
CreateGatewayTargetInput,
GatewayInvokeRequest,
GatewayInvokeResponse,
} from "../handlers/gateway/types";
import type { AwsClients, CoreOptions } from "./types";
import type { Logger } from "../logging";
import { abortable } from "./abortable";
import type { AwsClients, CoreFetch, CoreOptions } from "./types";
import { toClientConfig } from "./utils";

const DEFAULT_CONNECTOR_PAGE_SIZE = 100;
const MAX_CONNECTOR_TARGET_PAGES = 101;

async function* emptyBody(): AsyncGenerator<Uint8Array> {}

function toQuery(url: URL): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {};
for (const [name, value] of url.searchParams) {
const previous = query[name];
if (previous === undefined) query[name] = value;
else if (Array.isArray(previous)) previous.push(value);
else query[name] = [previous, value];
}
return query;
}

export class GatewayClient implements CoreGatewayClient {
constructor(private readonly clients: AwsClients) {}
constructor(
private readonly clients: AwsClients,
private readonly fetch: CoreFetch,
private readonly logger: Logger,
) {}

async createGateway(
input: CreateGatewayInput,
Expand All @@ -52,10 +73,122 @@ export class GatewayClient implements CoreGatewayClient {
);
}

async getGateway(id: string, options: CoreOptions): Promise<GetGatewayResponse> {
async invokeGateway(
request: GatewayInvokeRequest,
options: CoreOptions,
signal?: AbortSignal,
): Promise<GatewayInvokeResponse> {
const logger = this.logger.child({
operation: "invokeGateway",
authMode: request.authorizerType,
gatewayId: request.gatewayId,
method: request.method,
region: options.region,
});
const url = new URL(request.url);
if (url.protocol !== "https:") {
throw new TypeError("Gateway invocation requires an HTTPS URL");
}

const headers = new Headers(request.applicationHeaders);
try {
if (request.contentType !== undefined) headers.set("Content-Type", request.contentType);
if (request.accept !== undefined) headers.set("Accept", request.accept);
if (request.runtimeSessionId !== undefined) {
headers.set("X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId);
}
if (request.mcpSessionId !== undefined) {
headers.set("Mcp-Session-Id", request.mcpSessionId);
}
if (request.mcpProtocolVersion !== undefined) {
headers.set("Mcp-Protocol-Version", request.mcpProtocolVersion);
}
if (request.authorizerType === "CUSTOM_JWT") {
headers.set("Authorization", `Bearer ${request.bearerToken}`);
}
} catch {
throw new TypeError("Invalid Gateway request header");
}

let fetchHeaders: RequestInit["headers"] = headers;
try {
if (request.authorizerType === "AWS_IAM" || request.authorizerType === "AUTHENTICATE_ONLY") {
const client = this.clients.data(toClientConfig(options));
const signer = await client.config.signer({
name: "sigv4",
signingName: "bedrock-agentcore",
signingRegion: options.region,
properties: {},
});
const signed = await signer.sign({
method: request.method,
protocol: url.protocol,
hostname: url.hostname,
...(url.port && { port: Number(url.port) }),
path: url.pathname,
query: toQuery(url),
headers: {
...Object.fromEntries(headers.entries()),
host: url.host,
},
...(request.payload !== undefined && { body: request.payload }),
});
fetchHeaders = signed.headers;
}

const response = await this.fetch(url, {
method: request.method,
redirect: "manual",
headers: fetchHeaders,
...(request.payload !== undefined && {
body: request.payload as RequestInit["body"],
}),
signal,
});
if (!response.ok) {
logger
.child({ httpStatusCode: response.status })
.debug("Gateway invocation returned a non-success response");
}

const body = (response.body as AsyncIterable<Uint8Array> | null) ?? emptyBody();
return {
statusCode: response.status,
contentType: response.headers.get("content-type") ?? "",
runtimeSessionId:
response.headers.get("x-amzn-bedrock-agentcore-runtime-session-id") ?? undefined,
mcpSessionId: response.headers.get("mcp-session-id") ?? undefined,
mcpProtocolVersion: response.headers.get("mcp-protocol-version") ?? undefined,
requestId:
response.headers.get("x-amzn-requestid") ??
response.headers.get("x-amz-request-id") ??
undefined,
body: signal ? abortable(body, signal) : body,
};
} catch (error) {
if (signal?.aborted) throw signal.reason ?? error;
logger
.child({
errorName:
error instanceof TypeError
? "TypeError"
: error instanceof Error
? "Error"
: typeof error,
})
.debug("Gateway invocation transport failed");
throw new Error("Gateway invocation failed");
}
}

async getGateway(
id: string,
options: CoreOptions,
signal?: AbortSignal,
): Promise<GetGatewayResponse> {
return this.clients
.control(toClientConfig(options))
.send(new GetGatewayCommand({ gatewayIdentifier: id }));
.send(new GetGatewayCommand({ gatewayIdentifier: id }), { abortSignal: signal });
}

async listGateways(
Expand Down
Loading
Loading