1import * as control from "@distilled.cloud/aws/bedrock-agentcore-control";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import {
11 AgentCoreProvisioningFailed,
12 createGatewayName,
13 readAgentCoreTags,
14 retryWhileConflict,
15 retryWhileValidation,
16 syncAgentCoreTags,
17 unredact,
18} from "./internal.ts";
19
20export interface GatewayProps {
21 /**
22 * Name of the gateway. Must match `([0-9a-zA-Z][-]?){1,100}` (hyphens
23 * allowed, never consecutive). If omitted, a deterministic physical name is
24 * generated from the app, stage, and logical ID. Changing the name triggers
25 * a replacement.
26 */
27 name?: string;
28 /**
29 * A description of the gateway.
30 */
31 description?: string;
32 /**
33 * The ARN of an IAM role the gateway assumes to invoke its targets. Must
34 * trust `bedrock-agentcore.amazonaws.com`.
35 */
36 roleArn: string;
37 /**
38 * The protocol the gateway speaks to callers.
39 * @default "MCP"
40 */
41 protocolType?: control.GatewayProtocolType;
42 /**
43 * Protocol-specific configuration (MCP instructions, search, supported
44 * versions). Passed through to the AgentCore API unchanged.
45 */
46 protocolConfiguration?: control.GatewayProtocolConfiguration;
47 /**
48 * How inbound callers are authorized. `AWS_IAM` uses SigV4; `CUSTOM_JWT`
49 * requires `authorizerConfiguration`.
50 * @default "AWS_IAM"
51 */
52 authorizerType?: control.AuthorizerType;
53 /**
54 * Authorizer configuration (JWT discovery URL, allowed audiences/clients).
55 * Required when `authorizerType` is `CUSTOM_JWT`.
56 */
57 authorizerConfiguration?: control.AuthorizerConfiguration;
58 /**
59 * The ARN of a KMS key used to encrypt gateway data.
60 */
61 kmsKeyArn?: string;
62 /**
63 * Set to `DEBUG` to surface detailed exception traces to callers.
64 */
65 exceptionLevel?: control.ExceptionLevel;
66 /**
67 * Tags to apply to the gateway. Merged with internal Alchemy tags.
68 */
69 tags?: Record<string, string>;
70}
71
72export interface Gateway extends Resource<
73 "AWS.BedrockAgentCore.Gateway",
74 GatewayProps,
75 {
76 /**
77 * The unique identifier of the gateway.
78 */
79 gatewayId: string;
80 /**
81 * The ARN of the gateway.
82 */
83 gatewayArn: string;
84 /**
85 * The MCP endpoint URL clients connect to (available once the gateway is
86 * `READY`).
87 */
88 gatewayUrl: string | undefined;
89 /**
90 * Name of the gateway.
91 */
92 name: string;
93 /**
94 * Current status of the gateway (e.g. `READY`).
95 */
96 status: string;
97 }
98> {}
99
100/**
101 * An Amazon Bedrock AgentCore Gateway — a managed MCP endpoint that turns
102 * APIs and Lambda functions into agent-callable tools.
103 *
104 * A gateway fronts one or more targets (OpenAPI specs, Smithy models, Lambda
105 * functions) behind a single MCP URL with centralized authorization (SigV4 or
106 * JWT).
107 *
108 * ### Creating Gateways
109 * **Example:** IAM-Authorized MCP Gateway
110 * ```typescript
111 * import * as AgentCore from "alchemy/AWS/BedrockAgentCore";
112 * import * as IAM from "alchemy/AWS/IAM";
113 *
114 * const role = yield* IAM.Role("GatewayRole", {
115 * assumeRolePolicyDocument: {
116 * Version: "2012-10-17",
117 * Statement: [
118 * {
119 * Effect: "Allow",
120 * Principal: { Service: "bedrock-agentcore.amazonaws.com" },
121 * Action: ["sts:AssumeRole"],
122 * },
123 * ],
124 * },
125 * });
126 *
127 * const gateway = yield* AgentCore.Gateway("ToolGateway", {
128 * roleArn: role.roleArn,
129 * authorizerType: "AWS_IAM",
130 * });
131 * ```
132 *
133 * **Example:** JWT-Authorized Gateway
134 * ```typescript
135 * const gateway = yield* AgentCore.Gateway("JwtGateway", {
136 * roleArn: role.roleArn,
137 * authorizerType: "CUSTOM_JWT",
138 * authorizerConfiguration: {
139 * customJWTAuthorizer: {
140 * discoveryUrl: `https://cognito-idp.us-west-2.amazonaws.com/${userPool.userPoolId}/.well-known/openid-configuration`,
141 * allowedClients: [client.clientId],
142 * },
143 * },
144 * });
145 * ```
146 *
147 * @resource
148 */
149export const Gateway = Resource<Gateway>("AWS.BedrockAgentCore.Gateway");
150
151/** Statuses indicating an in-flight transition to wait out. */
152const GATEWAY_TRANSIENT = new Set(["CREATING", "UPDATING", "DELETING"]);
153
154export const GatewayProvider = () =>
155 Provider.effect(
156 Gateway,
157 Effect.gen(function* () {
158 const createName = Effect.fn(function* (
159 id: string,
160 props: Pick<GatewayProps, "name">,
161 ) {
162 return props.name ?? (yield* createGatewayName(id));
163 });
164
165 const getGatewayOrUndefined = Effect.fn(function* (
166 gatewayIdentifier: string,
167 ) {
168 return yield* control
169 .getGateway({ gatewayIdentifier })
170 .pipe(
171 Effect.catchTag("ResourceNotFoundException", () =>
172 Effect.succeed(undefined),
173 ),
174 );
175 });
176
177 const findByName = Effect.fn(function* (name: string) {
178 const pages = yield* control.listGateways
179 .pages({})
180 .pipe(Stream.runCollect);
181 const summary = Array.from(pages)
182 .flatMap((page) => page.items ?? [])
183 .find((s) => s.name === name);
184 return summary === undefined
185 ? undefined
186 : yield* getGatewayOrUndefined(summary.gatewayId);
187 });
188
189 const waitForSettled = Effect.fn(function* (gatewayId: string) {
190 return yield* getGatewayOrUndefined(gatewayId).pipe(
191 Effect.repeat({
192 schedule: Schedule.fixed("5 seconds"),
193 until: (g) => g === undefined || !GATEWAY_TRANSIENT.has(g.status),
194 times: 36,
195 }),
196 );
197 });
198
199 const toAttributes = (gateway: control.GetGatewayResponse) => ({
200 gatewayId: gateway.gatewayId,
201 gatewayArn: gateway.gatewayArn,
202 gatewayUrl: gateway.gatewayUrl,
203 name: gateway.name,
204 status: gateway.status,
205 });
206
207 return Gateway.Provider.of({
208 stables: ["gatewayId", "gatewayArn", "name"],
209
210 list: () =>
211 Effect.gen(function* () {
212 const pages = yield* control.listGateways
213 .pages({})
214 .pipe(Stream.runCollect);
215 const summaries = Array.from(pages).flatMap(
216 (page) => page.items ?? [],
217 );
218 const hydrated = yield* Effect.forEach(
219 summaries,
220 (s) => getGatewayOrUndefined(s.gatewayId),
221 { concurrency: 5 },
222 );
223 return hydrated.filter((g) => g !== undefined).map(toAttributes);
224 }),
225
226 read: Effect.fn(function* ({ id, olds, output }) {
227 const gateway = output?.gatewayId
228 ? yield* getGatewayOrUndefined(output.gatewayId)
229 : yield* findByName(
230 yield* createName(id, olds ?? ({} as GatewayProps)),
231 );
232 if (gateway === undefined || gateway.status === "DELETING") {
233 return undefined;
234 }
235 const attrs = toAttributes(gateway);
236 const tags = yield* readAgentCoreTags(gateway.gatewayArn);
237 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
238 }),
239
240 diff: Effect.fn(function* ({ id, news, olds }) {
241 if (!isResolved(news)) return undefined;
242 const oldName = yield* createName(id, olds ?? ({} as GatewayProps));
243 const newName = yield* createName(id, news ?? ({} as GatewayProps));
244 if (oldName !== newName) {
245 return { action: "replace" } as const;
246 }
247 if (
248 (olds?.kmsKeyArn ?? undefined) !== (news?.kmsKeyArn ?? undefined)
249 ) {
250 return { action: "replace" } as const;
251 }
252 // description, role, authorizer, protocol config, and tags converge
253 // via update.
254 }),
255
256 reconcile: Effect.fn(function* ({ id, news, output, session }) {
257 const props = news ?? ({} as GatewayProps);
258 const name = output?.name ?? (yield* createName(id, props));
259 const internalTags = yield* createInternalTags(id);
260 const desiredTags = { ...props.tags, ...internalTags };
261 const protocolType = props.protocolType ?? "MCP";
262 const authorizerType = props.authorizerType ?? "AWS_IAM";
263
264 // 1. OBSERVE
265 let gateway = output?.gatewayId
266 ? yield* getGatewayOrUndefined(output.gatewayId)
267 : undefined;
268 if (gateway === undefined) {
269 gateway = yield* findByName(name);
270 }
271
272 // 2. ENSURE — a freshly created IAM role is not instantly assumable
273 // by the AgentCore service principal, so ride out the transient
274 // ValidationException; tolerate the name-exists conflict race.
275 if (gateway === undefined) {
276 const created = yield* control
277 .createGateway({
278 name,
279 description: props.description,
280 roleArn: props.roleArn,
281 protocolType,
282 protocolConfiguration: props.protocolConfiguration,
283 authorizerType,
284 authorizerConfiguration: props.authorizerConfiguration,
285 kmsKeyArn: props.kmsKeyArn,
286 exceptionLevel: props.exceptionLevel,
287 tags: desiredTags,
288 })
289 .pipe(
290 retryWhileValidation,
291 Effect.catchTag("ConflictException", () => findByName(name)),
292 );
293 gateway =
294 created === undefined
295 ? yield* findByName(name)
296 : yield* getGatewayOrUndefined(created.gatewayId);
297 }
298 if (gateway === undefined) {
299 return yield* new AgentCoreProvisioningFailed({
300 message: `gateway '${name}' was neither created nor found`,
301 });
302 }
303
304 gateway = (yield* waitForSettled(gateway.gatewayId)) ?? gateway;
305 if (gateway.status === "FAILED") {
306 return yield* new AgentCoreProvisioningFailed({
307 message: `gateway '${name}' failed: ${(gateway.statusReasons ?? []).join("; ")}`,
308 });
309 }
310
311 // 3. SYNC — converge mutable settings from OBSERVED state.
312 const drifted =
313 (unredact(gateway.description) ?? undefined) !==
314 (props.description ?? unredact(gateway.description)) ||
315 gateway.roleArn !== props.roleArn ||
316 gateway.authorizerType !== authorizerType ||
317 (gateway.protocolType ?? "MCP") !== protocolType ||
318 (gateway.exceptionLevel ?? undefined) !==
319 (props.exceptionLevel ?? undefined) ||
320 JSON.stringify(gateway.authorizerConfiguration ?? null) !==
321 JSON.stringify(
322 props.authorizerConfiguration ??
323 gateway.authorizerConfiguration ??
324 null,
325 ) ||
326 (props.protocolConfiguration !== undefined &&
327 JSON.stringify(gateway.protocolConfiguration ?? null) !==
328 JSON.stringify(props.protocolConfiguration));
329 if (drifted) {
330 yield* control
331 .updateGateway({
332 gatewayIdentifier: gateway.gatewayId,
333 name,
334 description: props.description,
335 roleArn: props.roleArn,
336 protocolType,
337 protocolConfiguration: props.protocolConfiguration,
338 authorizerType,
339 authorizerConfiguration: props.authorizerConfiguration,
340 kmsKeyArn: props.kmsKeyArn,
341 exceptionLevel: props.exceptionLevel,
342 })
343 .pipe(retryWhileConflict);
344 gateway = (yield* waitForSettled(gateway.gatewayId)) ?? gateway;
345 }
346
347 // 3b. SYNC TAGS against observed cloud tags.
348 yield* syncAgentCoreTags(gateway.gatewayArn, desiredTags);
349
350 // 4. RETURN fresh attributes.
351 yield* session.note(gateway.gatewayId);
352 return toAttributes(gateway);
353 }),
354
355 // A gateway with live targets rejects deletion with a
356 // ConflictException until they are gone — the engine deletes targets
357 // first, but their teardown is eventually consistent.
358 delete: Effect.fn(function* ({ output }) {
359 yield* control
360 .deleteGateway({ gatewayIdentifier: output.gatewayId })
361 .pipe(
362 retryWhileConflict,
363 Effect.catchTag("ResourceNotFoundException", () =>
364 Effect.succeed(undefined),
365 ),
366 );
367 yield* control
368 .getGateway({ gatewayIdentifier: output.gatewayId })
369 .pipe(
370 Effect.map((g) => g.status as string),
371 Effect.catchTag("ResourceNotFoundException", () =>
372 Effect.succeed("GONE" as string),
373 ),
374 Effect.repeat({
375 schedule: Schedule.fixed("5 seconds"),
376 until: (status) => status === "GONE",
377 times: 24,
378 }),
379 );
380 }),
381 });
382 }),
383 );