1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Access.McpPortal" as const;
14type TypeId = typeof TypeId;
15
16export interface McpPortalProps {
17 /**
18 * The client-supplied portal identifier. Immutable — changing it
19 * triggers a replacement. If omitted, a deterministic id is generated
20 * from the app, stage, and logical ID.
21 * @default ${app}-${stage}-${id}
22 */
23 portalId?: string;
24 /**
25 * Display name of the portal. If omitted, the portal id is reused.
26 * @default the portal id
27 */
28 name?: string;
29 /**
30 * The hostname the portal is served on. Must belong to a zone on the
31 * account.
32 */
33 hostname: string;
34 /**
35 * Optional description of the portal.
36 */
37 description?: string;
38 /**
39 * Allow remote code execution in Dynamic Workers (beta). When omitted,
40 * the server-side default applies (observed: `true`).
41 */
42 allowCodeMode?: boolean;
43 /**
44 * Route outbound MCP traffic through the Zero Trust Secure Web
45 * Gateway.
46 * @default false
47 */
48 secureWebGateway?: boolean;
49}
50
51export type McpPortalAttributes = {
52 /** The portal id. */
53 portalId: string;
54 /** Account that owns the portal. */
55 accountId: string;
56 /** Observed display name. */
57 name: string;
58 /** Observed portal hostname. */
59 hostname: string;
60 /** Observed description, if any. */
61 description: string | undefined;
62 /** Whether Dynamic Workers code mode is allowed. */
63 allowCodeMode: boolean;
64 /** Whether outbound MCP traffic routes through the gateway. */
65 secureWebGateway: boolean;
66 /** RFC 3339 timestamp of when the portal was created, if reported. */
67 createdAt: string | undefined;
68};
69
70export type McpPortal = Resource<
71 TypeId,
72 McpPortalProps,
73 McpPortalAttributes,
74 never,
75 Providers
76>;
77
78/**
79 * A Cloudflare Zero Trust **AI Controls MCP portal** — a hosted gateway
80 * that aggregates MCP servers behind a single Access-protected hostname
81 * so administrators can govern which AI tools and prompts are exposed to
82 * users.
83 *
84 * The product surface is in beta and requires the AI Controls
85 * entitlement; accounts without it receive the typed `Forbidden` error
86 * on all writes. Register upstream endpoints with {@link McpServer}.
87 * Attaching registered servers to this portal is managed outside Alchemy.
88 * ### Creating an MCP portal
89 * **Example:** Minimal portal
90 * ```typescript
91 * const portal = yield* Cloudflare.Access.McpPortal("AiPortal", {
92 * hostname: "mcp.example.com",
93 * });
94 * ```
95 *
96 * **Example:** Portal with gateway egress
97 * ```typescript
98 * const portal = yield* Cloudflare.Access.McpPortal("AiPortal", {
99 * hostname: "mcp.example.com",
100 * description: "Company-approved AI tools",
101 * secureWebGateway: true,
102 * });
103 * ```
104 *
105 * @see https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/
106 *
107 * @resource
108 * @product Access
109 * @category Cloudflare One (Zero Trust)
110 */
111export const McpPortal = Resource<McpPortal>(TypeId);
112
113/**
114 * Returns true if the given value is an McpPortal resource.
115 */
116export const isMcpPortal = (value: unknown): value is McpPortal =>
117 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
118
119export const McpPortalProvider = () =>
120 Provider.succeed(McpPortal, {
121 stables: ["portalId", "accountId", "createdAt"],
122
123 list: Effect.fn(function* () {
124 const { accountId } = yield* yield* CloudflareEnvironment;
125 // Account-scoped collection; exhaustively paginate. The list rows
126 // carry the full portal shape, so each maps directly into the same
127 // Attributes `read` returns. Accounts without the AI Controls
128 // entitlement reject the route with the typed `Forbidden` — treat
129 // them as having no portals.
130 return yield* zeroTrust.listAccessAiControlMcpPortals
131 .pages({ accountId })
132 .pipe(
133 Stream.runCollect,
134 Effect.map((chunk) =>
135 Array.from(chunk).flatMap((page) =>
136 (page.result ?? []).map((portal) =>
137 toAttributes(portal, accountId),
138 ),
139 ),
140 ),
141 Effect.catchTag("Forbidden", () => Effect.succeed([])),
142 );
143 }),
144
145 diff: Effect.fn(function* ({ olds, news, output }) {
146 if (!isResolved(news)) return undefined;
147 // The portal id is the API identity — changing it is a replacement.
148 const oldId = output?.portalId ?? olds?.portalId;
149 if (
150 news.portalId !== undefined &&
151 oldId !== undefined &&
152 oldId !== news.portalId
153 ) {
154 return { action: "replace" } as const;
155 }
156 return undefined;
157 }),
158
159 read: Effect.fn(function* ({ id, output, olds }) {
160 const { accountId } = yield* yield* CloudflareEnvironment;
161 const acct = output?.accountId ?? accountId;
162
163 // The portal id is deterministic (client-supplied or derived from
164 // the logical id), so a direct read covers the cold case too.
165 const portalId =
166 output?.portalId ?? (yield* createPortalId(id, olds?.portalId));
167 const observed = yield* observePortal(acct, portalId);
168 return observed ? toAttributes(observed, acct) : undefined;
169 }),
170
171 reconcile: Effect.fn(function* ({ id, news, output }) {
172 const { accountId } = yield* yield* CloudflareEnvironment;
173 const portalId =
174 output?.portalId ?? (yield* createPortalId(id, news.portalId));
175 const name = news.name ?? portalId;
176
177 // 1. Observe.
178 const observed = yield* observePortal(accountId, portalId);
179
180 // 2. Ensure — create when missing.
181 if (!observed) {
182 const created = yield* zeroTrust.createAccessAiControlMcpPortal({
183 accountId,
184 id: portalId,
185 hostname: news.hostname,
186 name,
187 ...(news.description !== undefined
188 ? { description: news.description }
189 : {}),
190 ...(news.allowCodeMode !== undefined
191 ? { allowCodeMode: news.allowCodeMode }
192 : {}),
193 ...(news.secureWebGateway !== undefined
194 ? { secureWebGateway: news.secureWebGateway }
195 : {}),
196 });
197 return toAttributes(created, accountId);
198 }
199
200 // 3. Sync — update only when the observed state differs. Unset
201 // optional props mean "keep the observed value" (the API applies
202 // server-side defaults on create, e.g. allowCodeMode: true).
203 const dirty =
204 observed.name !== name ||
205 observed.hostname !== news.hostname ||
206 (news.description !== undefined &&
207 (observed.description || undefined) !== news.description) ||
208 (news.allowCodeMode !== undefined &&
209 (observed.allowCodeMode ?? false) !== news.allowCodeMode) ||
210 (news.secureWebGateway !== undefined &&
211 (observed.secureWebGateway ?? false) !== news.secureWebGateway);
212 if (!dirty) {
213 return toAttributes(observed, accountId);
214 }
215 const updated = yield* zeroTrust.updateAccessAiControlMcpPortal({
216 accountId,
217 id: portalId,
218 name,
219 hostname: news.hostname,
220 ...(news.description !== undefined
221 ? { description: news.description }
222 : {}),
223 ...(news.allowCodeMode !== undefined
224 ? { allowCodeMode: news.allowCodeMode }
225 : {}),
226 ...(news.secureWebGateway !== undefined
227 ? { secureWebGateway: news.secureWebGateway }
228 : {}),
229 });
230 return toAttributes(updated, accountId);
231 }),
232
233 delete: Effect.fn(function* ({ output }) {
234 yield* zeroTrust
235 .deleteAccessAiControlMcpPortal({
236 accountId: output.accountId,
237 id: output.portalId,
238 })
239 .pipe(Effect.catchTag("McpPortalNotFound", () => Effect.void));
240 }),
241 });
242
243/**
244 * Structural shape shared by create/read/update responses.
245 */
246type ObservedPortal = {
247 id: string;
248 hostname: string;
249 name: string;
250 allowCodeMode?: boolean | null;
251 createdAt?: string | null;
252 description?: string | null;
253 secureWebGateway?: boolean | null;
254};
255
256/**
257 * Read a portal by id, mapping "gone" to `undefined`.
258 */
259const observePortal = (accountId: string, id: string) =>
260 zeroTrust
261 .readAccessAiControlMcpPortal({ accountId, id })
262 .pipe(
263 Effect.catchTag("McpPortalNotFound", () => Effect.succeed(undefined)),
264 );
265
266const createPortalId = (id: string, portalId: string | undefined) =>
267 Effect.gen(function* () {
268 return portalId ?? (yield* createPhysicalName({ id, lowercase: true }));
269 });
270
271const toAttributes = (
272 portal: ObservedPortal,
273 accountId: string,
274): McpPortalAttributes => ({
275 portalId: portal.id,
276 accountId,
277 name: portal.name,
278 hostname: portal.hostname,
279 description: portal.description || undefined,
280 allowCodeMode: portal.allowCodeMode ?? false,
281 secureWebGateway: portal.secureWebGateway ?? false,
282 createdAt: portal.createdAt ?? undefined,
283});