1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.Access.McpServer" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Authentication method the gateway uses to reach the upstream MCP server.
20 */
21export type McpServerAuthType = "oauth" | "bearer" | "unauthenticated";
22
23/**
24 * Capability sync state reported by Cloudflare for an MCP server.
25 */
26export type McpServerStatus = "waiting" | "ready" | "stale" | "error";
27
28/**
29 * Whether administrative authentication is required before the server's
30 * capabilities can be synced.
31 */
32export type McpServerAuthenticationStatus =
33 | "not_required"
34 | "required"
35 | "connected"
36 | "stale"
37 | "manual";
38
39/**
40 * A server-wide override for a single tool or prompt exposed by the
41 * upstream MCP server.
42 */
43export interface McpServerCapabilityOverride {
44 /**
45 * Name of the tool or prompt capability to override.
46 */
47 name: string;
48 /**
49 * Custom name exposed for the capability.
50 */
51 alias?: string;
52 /**
53 * Custom description exposed for the capability.
54 */
55 description?: string;
56 /**
57 * Whether the capability is available through the MCP server.
58 */
59 enabled?: boolean;
60}
61
62/**
63 * A tool or prompt discovered from the upstream MCP server by a capability
64 * sync. The shape is whatever the upstream advertises (typically `name`,
65 * `description`, and an input schema).
66 */
67export type McpServerCapability = Record<string, unknown>;
68
69export interface McpServerProps {
70 /**
71 * The client-supplied server identifier. Immutable — changing it
72 * triggers a replacement. If omitted, a deterministic id is generated
73 * from the stack, stage, logical ID, and resource instance, capped at
74 * the API's 32-character limit.
75 * @default a generated ID of at most 32 characters
76 */
77 serverId?: string;
78 /**
79 * Display name of the server. If omitted, the server id is reused.
80 * @default the server id
81 */
82 name?: string;
83 /**
84 * URL of the upstream MCP endpoint, e.g. `https://mcp.example.com/mcp`.
85 * Create-only on the API: changing it triggers a delete-first replacement.
86 */
87 hostname: string;
88 /**
89 * Authentication method used to connect to the upstream MCP server.
90 * Create-only on the API: changing it triggers a delete-first replacement.
91 */
92 authType: McpServerAuthType;
93 /**
94 * Static credential for the upstream MCP server. For `bearer`, either a
95 * raw token (sent as `Authorization: Bearer <token>`) or a JSON-encoded
96 * `{"headers":{...}}` object for custom or multiple static headers, e.g.
97 * a Cloudflare Access service token. For `oauth`, the JSON-encoded OAuth
98 * configuration. Write-only — the API never returns it, so rotation is
99 * detected by comparing against the previously deployed value.
100 */
101 authCredentials?: Redacted.Redacted<string>;
102 /**
103 * Pre-registered OAuth `client_secret` for manually configured OAuth
104 * servers. Write-only — never returned by the API.
105 */
106 clientSecret?: Redacted.Redacted<string>;
107 /**
108 * Optional description of the server.
109 */
110 description?: string;
111 /**
112 * Route outbound traffic to this server through the Zero Trust Secure
113 * Web Gateway.
114 * @default false
115 */
116 secureWebGateway?: boolean;
117 /**
118 * Use the shared Cloudflare-owned OAuth callback endpoint as the
119 * `redirect_uri` for upstream on-behalf OAuth instead of the portal
120 * hostname.
121 * @default false
122 */
123 isSharedOauthCallbackEnabled?: boolean;
124 /**
125 * Server-wide tool overrides: disable, rename, or re-describe tools the
126 * upstream advertises. When omitted, the observed overrides are kept.
127 */
128 updatedTools?: McpServerCapabilityOverride[];
129 /**
130 * Server-wide prompt overrides: disable, rename, or re-describe prompts
131 * the upstream advertises. When omitted, the observed overrides are kept.
132 */
133 updatedPrompts?: McpServerCapabilityOverride[];
134 /**
135 * Run a capability sync (tool and prompt discovery against the upstream)
136 * after the server is created, its configuration changes, or sync is
137 * enabled after being disabled. Discovery
138 * problems are reported on the `status` and `error` attributes and never
139 * fail the deploy. Skipped while the server still requires an
140 * administrator to complete its OAuth authentication. Cloudflare may
141 * independently discover capabilities even when this option is false.
142 * @default true
143 */
144 sync?: boolean;
145}
146
147export type McpServerAttributes = {
148 /** The server id. */
149 serverId: string;
150 /** Account that owns the server. */
151 accountId: string;
152 /** Observed display name. */
153 name: string;
154 /** Observed upstream MCP endpoint URL. */
155 hostname: string;
156 /** Observed authentication method. */
157 authType: McpServerAuthType;
158 /** Observed description, if any. */
159 description: string | undefined;
160 /** Whether outbound traffic routes through the gateway. */
161 secureWebGateway: boolean;
162 /** Whether the shared Cloudflare OAuth callback is used. */
163 isSharedOauthCallbackEnabled: boolean;
164 /** Observed server-wide tool overrides. */
165 updatedTools: McpServerCapabilityOverride[];
166 /** Observed server-wide prompt overrides. */
167 updatedPrompts: McpServerCapabilityOverride[];
168 /** Tools discovered by the last successful capability sync. */
169 tools: McpServerCapability[];
170 /** Prompts discovered by the last successful capability sync. */
171 prompts: McpServerCapability[];
172 /** Current capability sync state, if reported. */
173 status: McpServerStatus | undefined;
174 /** Whether administrative OAuth authentication is still required. */
175 authenticationStatus: McpServerAuthenticationStatus | undefined;
176 /** Error from the last capability sync, if any. */
177 error: string | undefined;
178 /** RFC 3339 timestamp of the last sync attempt, if reported. */
179 lastSynced: string | undefined;
180 /** RFC 3339 timestamp of the last successful sync, if reported. */
181 lastSuccessfulSync: string | undefined;
182 /** RFC 3339 timestamp of when the server was created, if reported. */
183 createdAt: string | undefined;
184};
185
186export type McpServer = Resource<
187 TypeId,
188 McpServerProps,
189 McpServerAttributes,
190 never,
191 Providers
192>;
193
194/**
195 * A Cloudflare Zero Trust **AI Controls MCP server** — an upstream MCP
196 * endpoint registered on the account so its tools and prompts can be
197 * discovered, governed, and exposed to users through an MCP portal.
198 *
199 * Use {@link McpPortal} to create the gateway. Creating a server does not
200 * attach it to a portal; portal membership is managed outside Alchemy.
201 *
202 * The product surface is in beta and requires the AI Controls
203 * entitlement; accounts without it receive the typed `Forbidden` error
204 * on all writes. The upstream `hostname` and `authType` are create-only
205 * on the API — changing either triggers a delete-first replacement.
206 * Credentials are write-only: the API never returns them, so a rotation
207 * is detected by comparing against the previously deployed value.
208 * Existing servers without saved stack state require explicit adoption;
209 * their IDs alone do not prove ownership.
210 *
211 * ### Registering an MCP server
212 * **Example:** Public server without authentication
213 * ```typescript
214 * const docs = yield* Cloudflare.Access.McpServer("CloudflareDocs", {
215 * hostname: "https://docs.mcp.cloudflare.com/mcp",
216 * authType: "unauthenticated",
217 * });
218 * ```
219 *
220 * **Example:** Server behind a static bearer token
221 * ```typescript
222 * const internal = yield* Cloudflare.Access.McpServer("InternalTools", {
223 * hostname: "https://mcp.internal.example.com/mcp",
224 * authType: "bearer",
225 * authCredentials: yield* Config.Redacted("MCP_BEARER_TOKEN"),
226 * description: "Internal engineering tools",
227 * secureWebGateway: true,
228 * });
229 * ```
230 *
231 * **Example:** Server protected by a Cloudflare Access service token
232 * ```typescript
233 * const token = yield* Cloudflare.Access.ServiceToken("McpToken", {});
234 *
235 * const guarded = yield* Cloudflare.Access.McpServer("GuardedTools", {
236 * hostname: "https://mcp.example.com/mcp",
237 * authType: "bearer",
238 * authCredentials: Output.all(token.clientId, token.clientSecret).pipe(
239 * Output.map(([clientId, clientSecret]) =>
240 * Redacted.make(JSON.stringify({
241 * headers: {
242 * "cf-access-client-id": clientId,
243 * "cf-access-client-secret": Redacted.value(clientSecret!),
244 * },
245 * })),
246 * ),
247 * ),
248 * });
249 * ```
250 *
251 * ### Governing capabilities
252 * **Example:** Disable one tool and rename another
253 * ```typescript
254 * const curated = yield* Cloudflare.Access.McpServer("Curated", {
255 * hostname: "https://docs.mcp.cloudflare.com/mcp",
256 * authType: "unauthenticated",
257 * updatedTools: [
258 * { name: "migrate_pages_to_workers_guide", enabled: false },
259 * { name: "search_cloudflare_documentation", alias: "search_docs" },
260 * ],
261 * });
262 * ```
263 *
264 * **Example:** Register without an explicit capability sync
265 * ```typescript
266 * const deferred = yield* Cloudflare.Access.McpServer("Deferred", {
267 * hostname: "https://mcp.example.com/mcp",
268 * authType: "bearer",
269 * authCredentials: yield* Config.Redacted("MCP_BEARER_TOKEN"),
270 * sync: false,
271 * });
272 * ```
273 *
274 * ### Adopting an existing server
275 * **Example:** Take ownership of a server registered outside Alchemy
276 * ```typescript
277 * const server = yield* Cloudflare.Access.McpServer("Existing", {
278 * serverId: "existing-server",
279 * hostname: "https://mcp.example.com/mcp",
280 * authType: "unauthenticated",
281 * }).pipe(adopt(true));
282 * ```
283 *
284 * @see https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/
285 *
286 * @resource
287 * @product Access
288 * @category Cloudflare One (Zero Trust)
289 */
290export const McpServer = Resource<McpServer>(TypeId);
291
292/**
293 * Returns true if the given value is an McpServer resource.
294 */
295export const isMcpServer = (value: unknown): value is McpServer =>
296 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
297
298export const McpServerProvider = () =>
299 Provider.succeed(McpServer, {
300 stables: ["serverId", "accountId"],
301
302 list: Effect.fn(function* () {
303 const { accountId } = yield* yield* CloudflareEnvironment;
304 // Account-scoped collection; exhaustively paginate. The list rows
305 // carry the full server shape, so each maps directly into the same
306 // Attributes `read` returns. Accounts without the AI Controls
307 // entitlement reject the route with the typed `Forbidden` — treat
308 // them as having no servers.
309 return yield* zeroTrust.listAccessAiControlMcpServers
310 .pages({ accountId })
311 .pipe(
312 Stream.runCollect,
313 Effect.map((chunk) =>
314 Array.from(chunk).flatMap((page) =>
315 (page.result ?? []).map((server) =>
316 toAttributes(server, accountId),
317 ),
318 ),
319 ),
320 Effect.catchTag("Forbidden", () => Effect.succeed([])),
321 );
322 }),
323
324 diff: Effect.fn(function* ({ olds, news, output }) {
325 const { accountId } = yield* yield* CloudflareEnvironment;
326 if (!isResolved(news)) return undefined;
327 if (output !== undefined && output.accountId !== accountId) {
328 return { action: "replace" } as const;
329 }
330 // The server id is the API identity — changing it is a replacement.
331 const oldId = output?.serverId ?? olds?.serverId;
332 if (
333 news.serverId !== undefined &&
334 oldId !== undefined &&
335 oldId !== news.serverId
336 ) {
337 return { action: "replace" } as const;
338 }
339 if (
340 (output?.hostname ?? olds?.hostname) !== news.hostname ||
341 (output?.authType ?? olds?.authType) !== news.authType
342 ) {
343 return { action: "replace", deleteFirst: true } as const;
344 }
345 return undefined;
346 }),
347
348 read: Effect.fn(function* ({ id, output, olds }) {
349 const { accountId } = yield* yield* CloudflareEnvironment;
350 const acct = output?.accountId ?? accountId;
351
352 // The server id is deterministic (client-supplied or derived from
353 // the logical id), so a direct read covers the cold case too.
354 const serverId =
355 output?.serverId ?? (yield* createServerId(id, olds?.serverId));
356 const observed = yield* observeServer(acct, serverId).pipe(
357 // A failed create can leave props with an invalid ID but no output.
358 // That generation cannot exist; let destroy recover the saved state.
359 Effect.catchTag("McpServerInvalidId", (error) =>
360 output === undefined ? Effect.succeed(undefined) : Effect.fail(error),
361 ),
362 );
363 if (!observed) return undefined;
364 const attrs = toAttributes(observed, acct);
365 // MCP servers carry no ownership tags. An ID match alone does not
366 // authorize taking over a server when the stack has no saved output.
367 return output === undefined ? Unowned(attrs) : attrs;
368 }),
369
370 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
371 const { accountId } = yield* yield* CloudflareEnvironment;
372 const serverId =
373 output?.serverId ?? (yield* createServerId(id, news.serverId));
374 const name = news.name ?? serverId;
375
376 // 1. Observe.
377 let observed = yield* observeServer(accountId, serverId);
378
379 // 2. Ensure — immutable changes are handled by engine replacement.
380 let changed = false;
381 if (observed === undefined) {
382 observed = yield* zeroTrust.createAccessAiControlMcpServer({
383 accountId,
384 id: serverId,
385 authType: news.authType,
386 hostname: news.hostname,
387 ...mutableBody(news, name),
388 });
389 changed = true;
390 } else {
391 // 3. Sync — update only when the observed state differs. Unset
392 // optional props mean "keep the observed value". Credentials are
393 // write-only, so a rotation is detected against the previously
394 // deployed props rather than the cloud.
395 const dirty =
396 observed.name !== name ||
397 (news.description !== undefined &&
398 (observed.description || undefined) !== news.description) ||
399 (news.secureWebGateway !== undefined &&
400 (observed.secureWebGateway ?? false) !== news.secureWebGateway) ||
401 (news.isSharedOauthCallbackEnabled !== undefined &&
402 (observed.isSharedOauthCallbackEnabled ?? false) !==
403 news.isSharedOauthCallbackEnabled) ||
404 (news.updatedTools !== undefined &&
405 !overridesEqual(observed.updatedTools, news.updatedTools)) ||
406 (news.updatedPrompts !== undefined &&
407 !overridesEqual(observed.updatedPrompts, news.updatedPrompts)) ||
408 secretRotated(news.authCredentials, olds?.authCredentials) ||
409 secretRotated(news.clientSecret, olds?.clientSecret);
410 if (dirty) {
411 observed = yield* zeroTrust.updateAccessAiControlMcpServer({
412 accountId,
413 id: serverId,
414 ...mutableBody(news, name),
415 });
416 changed = true;
417 }
418 }
419
420 // 4. Capability sync — best effort after a configuration change or
421 // when sync is enabled, and never while an administrator still
422 // has to complete the OAuth flow.
423 // Upstream discovery problems come back in the response body
424 // (`status`/`error`) and are matched as typed McpServerSyncFailure errors.
425 if (
426 (changed || olds?.sync === false) &&
427 news.sync !== false &&
428 observed.authenticationStatus !== "required"
429 ) {
430 yield* zeroTrust
431 .syncAccessAiControlMcpServer({ accountId, id: serverId })
432 .pipe(
433 Effect.catchTag("McpServerSyncFailure", (error) =>
434 Effect.logDebug(
435 `capability sync for MCP server ${serverId} failed`,
436 error.body,
437 ),
438 ),
439 );
440 // 5. Return — re-read so the discovered capabilities are reported.
441 observed = (yield* observeServer(accountId, serverId)) ?? observed;
442 }
443
444 return toAttributes(observed, accountId);
445 }),
446
447 delete: Effect.fn(function* ({ output }) {
448 yield* zeroTrust
449 .deleteAccessAiControlMcpServer({
450 accountId: output.accountId,
451 id: output.serverId,
452 })
453 .pipe(Effect.catchTag("McpServerNotFound", () => Effect.void));
454 }),
455 });
456
457/**
458 * Structural shape of a capability override as returned by the API.
459 */
460type ObservedOverride = {
461 name: string;
462 alias?: string | null;
463 description?: string | null;
464 enabled?: boolean | null;
465};
466
467/**
468 * Structural shape shared by create/read/update/list responses.
469 */
470type ObservedServer = zeroTrust.ReadAccessAiControlMcpServerResponse;
471
472/**
473 * Read a server by id, mapping "gone" to `undefined`.
474 */
475const observeServer = (accountId: string, id: string) =>
476 zeroTrust
477 .readAccessAiControlMcpServer({ accountId, id })
478 .pipe(
479 Effect.catchTag("McpServerNotFound", () => Effect.succeed(undefined)),
480 );
481
482const createServerId = (id: string, serverId: string | undefined) =>
483 Effect.gen(function* () {
484 return (
485 serverId ??
486 (yield* createPhysicalName({ id, lowercase: true, maxLength: 32 }))
487 );
488 });
489
490/**
491 * The request members shared by create and update, derived from the
492 * desired props. Unset optional props are omitted so the API keeps (or
493 * defaults) the server-side value.
494 */
495const mutableBody = (news: McpServerProps, name: string) => ({
496 name,
497 ...(news.description !== undefined ? { description: news.description } : {}),
498 ...(news.secureWebGateway !== undefined
499 ? { secureWebGateway: news.secureWebGateway }
500 : {}),
501 ...(news.isSharedOauthCallbackEnabled !== undefined
502 ? { isSharedOauthCallbackEnabled: news.isSharedOauthCallbackEnabled }
503 : {}),
504 ...(news.updatedTools !== undefined
505 ? { updatedTools: news.updatedTools }
506 : {}),
507 ...(news.updatedPrompts !== undefined
508 ? { updatedPrompts: news.updatedPrompts }
509 : {}),
510 ...(news.authCredentials !== undefined
511 ? { authCredentials: Redacted.value(news.authCredentials) }
512 : {}),
513 ...(news.clientSecret !== undefined
514 ? { clientSecret: Redacted.value(news.clientSecret) }
515 : {}),
516});
517
518/**
519 * A write-only secret changed when it is set and differs from what was
520 * last deployed (including the first time it is set).
521 */
522const secretRotated = (
523 desired: Redacted.Redacted<string> | undefined,
524 previous: Redacted.Redacted<string> | undefined,
525): boolean =>
526 desired !== undefined &&
527 (previous === undefined ||
528 Redacted.value(previous) !== Redacted.value(desired));
529
530/**
531 * Drop null/undefined members so API echoes and desired overrides compare
532 * structurally.
533 */
534const normalizeOverride = (
535 override: ObservedOverride,
536): McpServerCapabilityOverride => ({
537 name: override.name,
538 ...(override.alias != null ? { alias: override.alias } : {}),
539 ...(override.description != null
540 ? { description: override.description }
541 : {}),
542 ...(override.enabled != null ? { enabled: override.enabled } : {}),
543});
544
545const normalizeOverrides = (
546 overrides: ReadonlyArray<ObservedOverride> | null | undefined,
547): McpServerCapabilityOverride[] => (overrides ?? []).map(normalizeOverride);
548
549const overridesEqual = (
550 observed: ReadonlyArray<ObservedOverride> | null | undefined,
551 desired: ReadonlyArray<ObservedOverride>,
552): boolean => {
553 const byName = (
554 a: McpServerCapabilityOverride,
555 b: McpServerCapabilityOverride,
556 ) => a.name.localeCompare(b.name);
557 return (
558 JSON.stringify(normalizeOverrides(observed).sort(byName)) ===
559 JSON.stringify(normalizeOverrides(desired).sort(byName))
560 );
561};
562
563const toAttributes = (
564 server: ObservedServer,
565 accountId: string,
566): McpServerAttributes => ({
567 serverId: server.id,
568 accountId,
569 name: server.name,
570 hostname: server.hostname,
571 authType: server.authType,
572 description: server.description || undefined,
573 secureWebGateway: server.secureWebGateway ?? false,
574 isSharedOauthCallbackEnabled: server.isSharedOauthCallbackEnabled ?? false,
575 updatedTools: normalizeOverrides(server.updatedTools),
576 updatedPrompts: normalizeOverrides(server.updatedPrompts),
577 tools: Array.from(server.tools ?? []),
578 prompts: Array.from(server.prompts ?? []),
579 status: server.status ?? undefined,
580 authenticationStatus: server.authenticationStatus ?? undefined,
581 error: server.error || undefined,
582 lastSynced: server.lastSynced ?? undefined,
583 lastSuccessfulSync: server.lastSuccessfulSync ?? undefined,
584 createdAt: server.createdAt ?? undefined,
585});