Skip to content

Commit be653e2

Browse files
committed
feat(gateway): add headless invoke command
1 parent 74e9bd5 commit be653e2

20 files changed

Lines changed: 1759 additions & 150 deletions

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ agentcore # interactive TUI
8383
├── gateway # inspect AgentCore Gateways
8484
│ ├── get # get a Gateway by id
8585
│ ├── list # list Gateways (server-side paginated)
86+
│ ├── invoke # send a headless request through a Gateway
8687
│ ├── target
8788
│ │ ├── get # get a Target under a Gateway
8889
│ │ └── list # list Targets under a Gateway
@@ -156,6 +157,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
156157
# Inspect Gateway resources without project configuration or deployment
157158
agentcore gateway get --id <gatewayId>
158159
agentcore gateway list --max-results 20
160+
agentcore gateway invoke --id <gatewayId> --payload file://request.json
159161
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
160162
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
161163
agentcore gateway rule get --gateway-id <gatewayId> --rule-id <ruleId>
@@ -207,6 +209,62 @@ Source-aware values: any field flag documented as such accepts the value inline,
207209
`file://` convention). A command reads stdin from at most one flag. For example,
208210
`--instructions file://order-quality.txt` or `--instructions -`.
209211

212+
### Invoke a Gateway
213+
214+
Gateway Invoke is a headless, project-independent HTTP request command. It gets
215+
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
216+
the Gateway's authorizer, and preserves the request and response bodies.
217+
218+
```bash
219+
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
220+
agentcore gateway invoke \
221+
--id <gatewayId> \
222+
--payload '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agentcore-cli","version":"1"}}}' \
223+
--accept 'application/json, text/event-stream' \
224+
--mcp-protocol-version 2025-03-26
225+
226+
# HTTP target: --path is relative to the Gateway origin.
227+
agentcore gateway invoke \
228+
--id <gatewayId> \
229+
--path support-agent/invocations \
230+
--payload file://request.json \
231+
--session-id <runtimeSessionId>
232+
233+
# Inference target.
234+
agentcore gateway invoke \
235+
--id <gatewayId> \
236+
--path inference/v1/messages \
237+
--payload file://message.json \
238+
--json
239+
240+
# GET requests do not accept a payload.
241+
agentcore gateway invoke \
242+
--id <gatewayId> \
243+
--method GET \
244+
--path inference/v1/models
245+
```
246+
247+
`--path` replaces the path in the returned Gateway URL while retaining its
248+
origin. It must remain relative to the selected Gateway and may include a query
249+
string. Omitting it uses the returned `gatewayUrl` exactly. Supported methods
250+
are `GET`, `POST` (the default), and `DELETE`. POST requires `--payload`; DELETE
251+
may include one. Payloads accept inline bytes, `file://<path>`, or `-` for stdin.
252+
253+
Authentication follows `GetGateway.authorizerType`: `AWS_IAM` requests use
254+
SigV4, `CUSTOM_JWT` and `AUTHENTICATE_ONLY` require `--bearer-token`, and `NONE`
255+
uses unsigned HTTPS. Bearer tokens accept inline, `file://`, or stdin sources;
256+
payload and token cannot both read stdin.
257+
258+
Raw responses stream exact bytes to stdout. `--output-file` streams those bytes
259+
to disk, while `--json` buffers one envelope containing status, selected session
260+
and request metadata, body encoding, and body. Binary or unknown output requires
261+
`--output-file` or `--json` when stdout is a terminal. Response metadata goes to
262+
stderr in raw and file modes.
263+
264+
Gateway Invoke V1 has no TUI, required request-type selector, tool/model
265+
discovery command, or protocol-specific payload builder. Callers provide the
266+
Gateway-relative route and protocol payload directly.
267+
210268
### Invoke a Runtime
211269

212270
Headless invocation accepts inline, file, or stdin payload bytes:

src/core/gateway.tsx

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,33 @@ import type {
2323
CreateGatewayInput,
2424
CreateGatewayRuleInput,
2525
CreateGatewayTargetInput,
26+
GatewayInvokeRequest,
27+
GatewayInvokeResponse,
2628
} from "../handlers/gateway/types";
27-
import type { AwsClients, CoreOptions } from "./types";
29+
import type { Logger } from "../logging";
30+
import { abortable } from "./abortable";
31+
import type { AwsClients, CoreFetch, CoreOptions } from "./types";
2832
import { toClientConfig } from "./utils";
2933

34+
async function* emptyBody(): AsyncGenerator<Uint8Array> {}
35+
36+
function toQuery(url: URL): Record<string, string | string[]> {
37+
const query: Record<string, string | string[]> = {};
38+
for (const [name, value] of url.searchParams) {
39+
const previous = query[name];
40+
if (previous === undefined) query[name] = value;
41+
else if (Array.isArray(previous)) previous.push(value);
42+
else query[name] = [previous, value];
43+
}
44+
return query;
45+
}
46+
3047
export class GatewayClient implements CoreGatewayClient {
31-
constructor(private readonly clients: AwsClients) {}
48+
constructor(
49+
private readonly clients: AwsClients,
50+
private readonly fetch: CoreFetch,
51+
private readonly logger: Logger,
52+
) {}
3253

3354
async createGateway(
3455
input: CreateGatewayInput,
@@ -45,10 +66,128 @@ export class GatewayClient implements CoreGatewayClient {
4566
);
4667
}
4768

48-
async getGateway(id: string, options: CoreOptions): Promise<GetGatewayResponse> {
69+
async invokeGateway(
70+
request: GatewayInvokeRequest,
71+
options: CoreOptions,
72+
signal?: AbortSignal,
73+
): Promise<GatewayInvokeResponse> {
74+
const logger = this.logger.child({
75+
operation: "invokeGateway",
76+
authMode: request.authorizerType,
77+
gatewayId: request.gatewayId,
78+
method: request.method,
79+
region: options.region,
80+
});
81+
const url = new URL(request.url);
82+
if (url.protocol !== "https:") {
83+
throw new TypeError("Gateway invocation requires an HTTPS URL");
84+
}
85+
86+
const headers = new Headers(request.applicationHeaders);
87+
try {
88+
if (request.contentType !== undefined) headers.set("Content-Type", request.contentType);
89+
if (request.accept !== undefined) headers.set("Accept", request.accept);
90+
if (request.runtimeSessionId !== undefined) {
91+
headers.set("X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", request.runtimeSessionId);
92+
}
93+
if (request.mcpSessionId !== undefined) {
94+
headers.set("Mcp-Session-Id", request.mcpSessionId);
95+
}
96+
if (request.mcpProtocolVersion !== undefined) {
97+
headers.set("Mcp-Protocol-Version", request.mcpProtocolVersion);
98+
}
99+
if (
100+
request.authorizerType === "CUSTOM_JWT" ||
101+
request.authorizerType === "AUTHENTICATE_ONLY"
102+
) {
103+
headers.set("Authorization", `Bearer ${request.bearerToken}`);
104+
}
105+
} catch {
106+
throw new TypeError("Invalid Gateway request header");
107+
}
108+
109+
let fetchHeaders: RequestInit["headers"] = headers;
110+
try {
111+
if (request.authorizerType === "AWS_IAM") {
112+
const client = this.clients.data(toClientConfig(options));
113+
const signer = await client.config.signer({
114+
name: "sigv4",
115+
signingName: "bedrock-agentcore",
116+
signingRegion: options.region,
117+
properties: {},
118+
});
119+
const signed = await signer.sign({
120+
method: request.method,
121+
protocol: url.protocol,
122+
hostname: url.hostname,
123+
...(url.port && { port: Number(url.port) }),
124+
path: url.pathname,
125+
query: toQuery(url),
126+
headers: {
127+
...Object.fromEntries(headers.entries()),
128+
host: url.host,
129+
},
130+
...(request.payload !== undefined && { body: request.payload }),
131+
});
132+
fetchHeaders = signed.headers;
133+
}
134+
135+
const response = await this.fetch(url, {
136+
method: request.method,
137+
redirect: "error",
138+
headers: fetchHeaders,
139+
...(request.payload !== undefined && {
140+
body: request.payload as RequestInit["body"],
141+
}),
142+
signal,
143+
});
144+
if (!response.ok) {
145+
logger
146+
.child({ httpStatusCode: response.status })
147+
.debug("Gateway invocation returned a non-success response");
148+
await response.body?.cancel().catch(() => undefined);
149+
throw new Error(`HTTP ${response.status}`);
150+
}
151+
152+
const body = (response.body as AsyncIterable<Uint8Array> | null) ?? emptyBody();
153+
return {
154+
statusCode: response.status,
155+
contentType: response.headers.get("content-type") ?? "",
156+
runtimeSessionId:
157+
response.headers.get("x-amzn-bedrock-agentcore-runtime-session-id") ?? undefined,
158+
mcpSessionId: response.headers.get("mcp-session-id") ?? undefined,
159+
mcpProtocolVersion: response.headers.get("mcp-protocol-version") ?? undefined,
160+
requestId:
161+
response.headers.get("x-amzn-requestid") ??
162+
response.headers.get("x-amz-request-id") ??
163+
undefined,
164+
body: signal ? abortable(body, signal) : body,
165+
};
166+
} catch (error) {
167+
if (signal?.aborted) throw signal.reason ?? error;
168+
if ((error as Error)?.message?.startsWith("HTTP ")) throw error;
169+
logger
170+
.child({
171+
errorName:
172+
error instanceof TypeError
173+
? "TypeError"
174+
: error instanceof Error
175+
? "Error"
176+
: typeof error,
177+
})
178+
.debug("Gateway invocation transport failed");
179+
throw new Error("Gateway invocation failed");
180+
}
181+
}
182+
183+
async getGateway(
184+
id: string,
185+
options: CoreOptions,
186+
signal?: AbortSignal,
187+
): Promise<GetGatewayResponse> {
49188
return this.clients
50189
.control(toClientConfig(options))
51-
.send(new GetGatewayCommand({ gatewayIdentifier: id }));
190+
.send(new GetGatewayCommand({ gatewayIdentifier: id }), { abortSignal: signal });
52191
}
53192

54193
async listGateways(

0 commit comments

Comments
 (0)