1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import * as Output from "@/Output";
5import * as Provider from "@/Provider";
6import { isResourceState, State } from "@/State";
7import * as Cause from "effect/Cause";
8import * as Test from "@/Test/Alchemy";
9import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import * as Schema from "effect/Schema";
14import * as Schedule from "effect/Schedule";
15import { MinimumLogLevel } from "effect/References";
16import * as FetchHttpClient from "effect/http/FetchHttpClient";
17
18const { test } = Test.make({ providers: Cloudflare.providers() });
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25// Deterministic identifiers — the server id is the API identity, so reruns
26// converge on the same server instead of leaking. The cases run concurrently,
27// so each MUST use a distinct server id.
28const SERVER_ID = "alchemy-test-mcp-server";
29const RECREATE_SERVER_ID = "alchemy-test-mcp-server-recreate";
30const REPLACE_SERVER_ID = "alchemy-test-mcp-server-replace";
31const REPLACE_SERVER_ID_V2 = "alchemy-test-mcp-replaced";
32const SYNC_SERVER_ID = "alchemy-test-mcp-server-sync";
33const DEFERRED_SYNC_SERVER_ID = "alchemy-test-mcp-deferred-sync";
34const TOKEN_SERVER_ID = "alchemy-test-mcp-service-token";
35
36// Placeholder upstreams on the standing test zone. These cases opt out of
37// Alchemy's explicit capability sync; Cloudflare may still discover in the background.
38const HOSTNAME = "https://mcp.alchemy-test-2.us/mcp";
39const HOSTNAME_V2 = "https://mcp-v2.alchemy-test-2.us/mcp";
40// A real, public, unauthenticated MCP server for the capability-sync case.
41const PUBLIC_HOSTNAME = "https://docs.mcp.cloudflare.com/mcp";
42
43test.provider(
44 "enabling sync discovers capabilities without changing server configuration",
45 (stack) =>
46 Effect.gen(function* () {
47 const { accountId } = yield* yield* CloudflareEnvironment;
48 yield* stack.destroy();
49
50 const fetch = yield* FetchHttpClient.Fetch;
51 let serverRequests = 0;
52 let syncRequests = 0;
53 // Observe real requests: Cloudflare may discover capabilities in the
54 // background even when Alchemy has not called the sync endpoint.
55 const trackSync = ((input, init) => {
56 const url = input instanceof Request ? input.url : String(input);
57 if (url.endsWith(`/mcp/servers/${DEFERRED_SYNC_SERVER_ID}`)) {
58 serverRequests++;
59 }
60 if (url.endsWith(`/mcp/servers/${DEFERRED_SYNC_SERVER_ID}/sync`)) {
61 syncRequests++;
62 }
63 return fetch(input, init);
64 }) as typeof globalThis.fetch;
65
66 const deploy = (sync: boolean | undefined) =>
67 stack
68 .deploy(
69 Cloudflare.Access.McpServer("DeferredSync", {
70 serverId: DEFERRED_SYNC_SERVER_ID,
71 hostname: PUBLIC_HOSTNAME,
72 authType: "unauthenticated",
73 sync,
74 }),
75 )
76 .pipe(Effect.provideService(FetchHttpClient.Fetch, trackSync));
77
78 const deferred = yield* deploy(false);
79 expect(serverRequests).toBeGreaterThan(0);
80 expect(syncRequests).toEqual(0);
81
82 const synced = yield* deploy(true);
83 expect(syncRequests).toEqual(1);
84 expect(synced.serverId).toEqual(deferred.serverId);
85 expect(synced.createdAt).toEqual(deferred.createdAt);
86 expect(synced.status).toEqual("ready");
87 expect(synced.tools.length).toBeGreaterThan(0);
88 expect(synced.lastSuccessfulSync).toBeDefined();
89
90 const live = yield* getLiveServer(accountId, DEFERRED_SYNC_SERVER_ID);
91 expect(live?.status).toEqual("ready");
92 expect(live?.tools.length).toBeGreaterThan(0);
93
94 const noop = yield* deploy(true);
95 expect(syncRequests).toEqual(1);
96 expect(noop.lastSynced).toEqual(synced.lastSynced);
97
98 yield* deploy(false);
99 expect(syncRequests).toEqual(1);
100 const defaultSync = yield* deploy(undefined);
101 expect(syncRequests).toEqual(2);
102 expect(defaultSync.status).toEqual("ready");
103 yield* deploy(undefined);
104 expect(syncRequests).toEqual(2);
105
106 yield* stack.destroy();
107 expect(
108 yield* getLiveServer(accountId, DEFERRED_SYNC_SERVER_ID),
109 ).toBeUndefined();
110 }).pipe(logLevel),
111 {
112 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
113 timeout: 120_000,
114 },
115);
116
117test.provider(
118 "service-token outputs resolve into redacted MCP authentication credentials",
119 (stack) =>
120 Effect.gen(function* () {
121 const { accountId } = yield* yield* CloudflareEnvironment;
122 yield* stack.destroy();
123
124 const deployed = yield* stack.deploy(
125 Effect.gen(function* () {
126 const token = yield* Cloudflare.Access.ServiceToken("McpToken", {});
127 const credentials = Output.all(
128 token.clientId,
129 token.clientSecret,
130 ).pipe(
131 Output.map(([clientId, clientSecret]) =>
132 Redacted.make(
133 JSON.stringify({
134 headers: {
135 "cf-access-client-id": clientId,
136 "cf-access-client-secret": Redacted.value(clientSecret!),
137 },
138 }),
139 ),
140 ),
141 );
142 const server = yield* Cloudflare.Access.McpServer("GuardedTools", {
143 serverId: TOKEN_SERVER_ID,
144 hostname: HOSTNAME,
145 authType: "bearer",
146 authCredentials: credentials,
147 sync: false,
148 });
149 return { token, server, credentials };
150 }),
151 );
152
153 expect(Redacted.isRedacted(deployed.credentials)).toBe(true);
154 // Compare without printing either credential on an assertion failure.
155 expect(
156 Redacted.value(deployed.credentials) ===
157 JSON.stringify({
158 headers: {
159 "cf-access-client-id": deployed.token.clientId,
160 "cf-access-client-secret": Redacted.value(
161 deployed.token.clientSecret!,
162 ),
163 },
164 }),
165 ).toBe(true);
166 expect(deployed.server.authType).toEqual("bearer");
167 const live = yield* getLiveServer(accountId, TOKEN_SERVER_ID);
168 expect(live?.authType).toEqual("bearer");
169
170 yield* stack.destroy();
171 expect(yield* getLiveServer(accountId, TOKEN_SERVER_ID)).toBeUndefined();
172 const deletedToken = yield* zeroTrust
173 .getAccessServiceTokenForAccount({
174 accountId,
175 serviceTokenId: deployed.token.serviceTokenId,
176 })
177 .pipe(
178 Effect.catchTag("AccessServiceTokenNotFound", () =>
179 Effect.succeed(undefined),
180 ),
181 );
182 expect(deletedToken).toBeUndefined();
183 }).pipe(logLevel),
184 {
185 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
186 timeout: 120_000,
187 },
188);
189
190// Read a server out-of-band, mapping "gone" to undefined.
191const getLiveServer = (accountId: string, id: string) =>
192 zeroTrust
193 .readAccessAiControlMcpServer({ accountId, id })
194 .pipe(
195 Effect.catchTag("McpServerNotFound", () => Effect.succeed(undefined)),
196 );
197
198test.provider(
199 "overlong server IDs surface the typed validation error and remain cleanable",
200 (stack) =>
201 Effect.gen(function* () {
202 const { accountId } = yield* yield* CloudflareEnvironment;
203 yield* stack.destroy();
204 const error = yield* zeroTrust
205 .readAccessAiControlMcpServer({
206 accountId,
207 id: "alchemy-test-mcp-server-id-is-too-long",
208 })
209 .pipe(Effect.flip);
210 expect(error._tag).toEqual("McpServerInvalidId");
211 const deployError = yield* stack
212 .deploy(
213 Cloudflare.Access.McpServer("InvalidId", {
214 serverId: "alchemy-test-mcp-server-id-is-too-long",
215 hostname: HOSTNAME,
216 authType: "unauthenticated",
217 sync: false,
218 }),
219 )
220 .pipe(Effect.flip);
221 expect(deployError._tag).toEqual("McpServerInvalidId");
222 yield* stack.destroy();
223 }).pipe(logLevel),
224 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
225);
226
227test.provider(
228 "create, update in place, and destroy an MCP server",
229 (stack) =>
230 Effect.gen(function* () {
231 const { accountId } = yield* yield* CloudflareEnvironment;
232
233 yield* stack.destroy();
234
235 const server = yield* stack.deploy(
236 Effect.gen(function* () {
237 return yield* Cloudflare.Access.McpServer("Upstream", {
238 serverId: SERVER_ID,
239 hostname: HOSTNAME,
240 authType: "bearer",
241 authCredentials: Redacted.make("alchemy-test-token-v1"),
242 description: "alchemy mcp server v1",
243 sync: false,
244 });
245 }),
246 );
247
248 expect(server.serverId).toEqual(SERVER_ID);
249 expect(server.accountId).toEqual(accountId);
250 expect(server.hostname).toEqual(HOSTNAME);
251 expect(server.authType).toEqual("bearer");
252 expect(server.name).toEqual(SERVER_ID);
253 expect(server.description).toEqual("alchemy mcp server v1");
254 expect(server.secureWebGateway).toEqual(false);
255 expect(server.isSharedOauthCallbackEnabled).toEqual(false);
256 expect(server.updatedTools).toEqual([]);
257 expect(server.updatedPrompts).toEqual([]);
258
259 const live = yield* getLiveServer(accountId, SERVER_ID);
260 expect(live?.id).toEqual(SERVER_ID);
261 expect(live?.hostname).toEqual(HOSTNAME);
262 expect(live?.authType).toEqual("bearer");
263
264 // Name, description, gateway, overrides, and a credential rotation
265 // converge in place — same server id.
266 const updated = yield* stack.deploy(
267 Effect.gen(function* () {
268 return yield* Cloudflare.Access.McpServer("Upstream", {
269 serverId: SERVER_ID,
270 name: "Alchemy MCP Server",
271 hostname: HOSTNAME,
272 authType: "bearer",
273 authCredentials: Redacted.make("alchemy-test-token-v2"),
274 description: "alchemy mcp server v2",
275 secureWebGateway: true,
276 updatedTools: [
277 { name: "search", enabled: false },
278 { name: "fetch", alias: "get_page" },
279 ],
280 sync: false,
281 });
282 }),
283 );
284 expect(updated.serverId).toEqual(SERVER_ID);
285 expect(updated.name).toEqual("Alchemy MCP Server");
286 expect(updated.description).toEqual("alchemy mcp server v2");
287 expect(updated.secureWebGateway).toEqual(true);
288 expect(updated.updatedTools).toHaveLength(2);
289 expect(
290 updated.updatedTools.find((tool) => tool.name === "search")?.enabled,
291 ).toEqual(false);
292 expect(
293 updated.updatedTools.find((tool) => tool.name === "fetch")?.alias,
294 ).toEqual("get_page");
295
296 const liveUpdated = yield* getLiveServer(accountId, SERVER_ID);
297 expect(liveUpdated?.name).toEqual("Alchemy MCP Server");
298 expect(liveUpdated?.secureWebGateway).toEqual(true);
299
300 // No-op redeploy keeps the same server without drift.
301 const noop = yield* stack.deploy(
302 Effect.gen(function* () {
303 return yield* Cloudflare.Access.McpServer("Upstream", {
304 serverId: SERVER_ID,
305 name: "Alchemy MCP Server",
306 hostname: HOSTNAME,
307 authType: "bearer",
308 authCredentials: Redacted.make("alchemy-test-token-v2"),
309 description: "alchemy mcp server v2",
310 secureWebGateway: true,
311 updatedTools: [
312 { name: "search", enabled: false },
313 { name: "fetch", alias: "get_page" },
314 ],
315 sync: false,
316 });
317 }),
318 );
319 expect(noop.serverId).toEqual(SERVER_ID);
320
321 yield* stack.destroy();
322
323 const afterDestroy = yield* getLiveServer(accountId, SERVER_ID);
324 expect(afterDestroy).toBeUndefined();
325 }).pipe(logLevel),
326 {
327 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
328 timeout: 90_000,
329 },
330);
331
332// `hostname` and `authType` are create-only on the API. Changing either must
333// converge by recreating the server under the same id rather than failing.
334test.provider(
335 "changing the upstream hostname or auth type recreates the server under the same id",
336 (stack) =>
337 Effect.gen(function* () {
338 const { accountId } = yield* yield* CloudflareEnvironment;
339
340 yield* stack.destroy();
341
342 const initial = yield* stack.deploy(
343 Effect.gen(function* () {
344 return yield* Cloudflare.Access.McpServer("Recreated", {
345 serverId: RECREATE_SERVER_ID,
346 hostname: HOSTNAME,
347 authType: "unauthenticated",
348 sync: false,
349 });
350 }),
351 );
352 expect(initial.serverId).toEqual(RECREATE_SERVER_ID);
353 expect(initial.authType).toEqual("unauthenticated");
354
355 const rehostPlan = yield* stack.plan(
356 Cloudflare.Access.McpServer("Recreated", {
357 serverId: RECREATE_SERVER_ID,
358 hostname: HOSTNAME_V2,
359 authType: "unauthenticated",
360 sync: false,
361 }),
362 );
363 const rehostChange = rehostPlan.resources.Recreated!;
364 expect(rehostChange.action).toEqual("replace");
365 if (rehostChange.action === "replace")
366 expect(rehostChange.deleteFirst).toBe(true);
367
368 const rehosted = yield* stack.deploy(
369 Effect.gen(function* () {
370 return yield* Cloudflare.Access.McpServer("Recreated", {
371 serverId: RECREATE_SERVER_ID,
372 hostname: HOSTNAME_V2,
373 authType: "unauthenticated",
374 sync: false,
375 });
376 }),
377 );
378 expect(rehosted.serverId).toEqual(RECREATE_SERVER_ID);
379 expect(rehosted.hostname).toEqual(HOSTNAME_V2);
380 expect(rehosted.authType).toEqual("unauthenticated");
381
382 const authPlan = yield* stack.plan(
383 Cloudflare.Access.McpServer("Recreated", {
384 serverId: RECREATE_SERVER_ID,
385 hostname: HOSTNAME_V2,
386 authType: "bearer",
387 authCredentials: Redacted.make("alchemy-test-token-recreate"),
388 sync: false,
389 }),
390 );
391 const authChange = authPlan.resources.Recreated!;
392 expect(authChange.action).toEqual("replace");
393 if (authChange.action === "replace")
394 expect(authChange.deleteFirst).toBe(true);
395
396 const recreated = yield* stack.deploy(
397 Effect.gen(function* () {
398 return yield* Cloudflare.Access.McpServer("Recreated", {
399 serverId: RECREATE_SERVER_ID,
400 hostname: HOSTNAME_V2,
401 authType: "bearer",
402 authCredentials: Redacted.make("alchemy-test-token-recreate"),
403 sync: false,
404 });
405 }),
406 );
407 expect(recreated.serverId).toEqual(RECREATE_SERVER_ID);
408 expect(recreated.hostname).toEqual(HOSTNAME_V2);
409 expect(recreated.authType).toEqual("bearer");
410
411 const live = yield* getLiveServer(accountId, RECREATE_SERVER_ID);
412 expect(live?.hostname).toEqual(HOSTNAME_V2);
413 expect(live?.authType).toEqual("bearer");
414
415 yield* stack.destroy();
416
417 const afterDestroy = yield* getLiveServer(accountId, RECREATE_SERVER_ID);
418 expect(afterDestroy).toBeUndefined();
419 }).pipe(logLevel),
420 {
421 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
422 timeout: 90_000,
423 },
424);
425
426// Canonical `list()` test (account collection): deploy a server, then resolve
427// the provider via the typed helper and assert the deployed server appears in
428// the exhaustively-paginated result.
429test.provider(
430 "generates a valid server ID and lists the deployed MCP server",
431 (stack) =>
432 Effect.gen(function* () {
433 const { accountId } = yield* yield* CloudflareEnvironment;
434
435 yield* stack.destroy();
436
437 const deployed = yield* stack.deploy(
438 Effect.gen(function* () {
439 return yield* Cloudflare.Access.McpServer(
440 "ServerWithALongLogicalNameForGeneratedIdCoverage",
441 {
442 hostname: HOSTNAME,
443 authType: "unauthenticated",
444 sync: false,
445 },
446 );
447 }),
448 );
449 expect(deployed.serverId.length).toBeLessThanOrEqual(32);
450
451 const provider = yield* Provider.findProvider(
452 Cloudflare.Access.McpServer,
453 );
454 const all = yield* provider.list();
455
456 expect(all.some((s) => s.serverId === deployed.serverId)).toBe(true);
457
458 yield* stack.destroy();
459
460 const afterDestroy = yield* getLiveServer(accountId, deployed.serverId);
461 expect(afterDestroy).toBeUndefined();
462 }).pipe(logLevel),
463 {
464 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
465 timeout: 90_000,
466 },
467);
468
469test.provider(
470 "changing the server ID replaces the server and removes the old ID",
471 (stack) =>
472 Effect.gen(function* () {
473 const { accountId } = yield* yield* CloudflareEnvironment;
474 yield* stack.destroy();
475
476 const deploy = (serverId: string) =>
477 stack.deploy(
478 Cloudflare.Access.McpServer("Replaced", {
479 serverId,
480 hostname: HOSTNAME,
481 authType: "unauthenticated",
482 sync: false,
483 }),
484 );
485
486 const initial = yield* deploy(REPLACE_SERVER_ID);
487 expect(initial.serverId).toEqual(REPLACE_SERVER_ID);
488
489 const replaced = yield* deploy(REPLACE_SERVER_ID_V2);
490 expect(replaced.serverId).toEqual(REPLACE_SERVER_ID_V2);
491 expect(
492 yield* getLiveServer(accountId, REPLACE_SERVER_ID),
493 ).toBeUndefined();
494 expect(
495 (yield* getLiveServer(accountId, REPLACE_SERVER_ID_V2))?.id,
496 ).toEqual(REPLACE_SERVER_ID_V2);
497
498 yield* stack.destroy();
499 expect(
500 yield* getLiveServer(accountId, REPLACE_SERVER_ID_V2),
501 ).toBeUndefined();
502 }).pipe(logLevel),
503 {
504 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
505 timeout: 90_000,
506 },
507);
508
509// The default deploy runs a capability sync against the upstream. Against a
510// real public server the discovered tools come back on the attributes.
511test.provider(
512 "sync discovers the capabilities of a public MCP server",
513 (stack) =>
514 Effect.gen(function* () {
515 const { accountId } = yield* yield* CloudflareEnvironment;
516
517 yield* stack.destroy();
518
519 const server = yield* stack.deploy(
520 Effect.gen(function* () {
521 return yield* Cloudflare.Access.McpServer("CloudflareDocs", {
522 serverId: SYNC_SERVER_ID,
523 hostname: PUBLIC_HOSTNAME,
524 authType: "unauthenticated",
525 });
526 }),
527 );
528
529 expect(server.serverId).toEqual(SYNC_SERVER_ID);
530 expect(server.status).toEqual("ready");
531 expect(server.tools.length).toBeGreaterThan(0);
532
533 yield* stack.destroy();
534
535 const afterDestroy = yield* getLiveServer(accountId, SYNC_SERVER_ID);
536 expect(afterDestroy).toBeUndefined();
537 }).pipe(logLevel),
538 {
539 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
540 timeout: 120_000,
541 },
542);
543
544test.provider(
545 "existing servers require adoption before their configuration can change",
546 (stack) =>
547 Effect.gen(function* () {
548 const { accountId } = yield* yield* CloudflareEnvironment;
549 yield* stack.destroy();
550 const serverId = "alchemy-test-mcp-adoption";
551 const external = yield* zeroTrust.createAccessAiControlMcpServer({
552 accountId,
553 id: serverId,
554 hostname: HOSTNAME,
555 authType: "unauthenticated",
556 name: "External MCP server",
557 });
558 const resource = () =>
559 Cloudflare.Access.McpServer("Adopted", {
560 serverId,
561 hostname: HOSTNAME,
562 authType: "unauthenticated",
563 name: "Adopted MCP server",
564 sync: false,
565 });
566 yield* Effect.gen(function* () {
567 const refused = yield* stack.deploy(resource()).pipe(
568 Effect.as(false),
569 Effect.catchCause((cause) =>
570 Effect.succeed(
571 cause.reasons.some(
572 (reason) =>
573 (Cause.isFailReason(reason) &&
574 reason.error instanceof OwnedBySomeoneElse) ||
575 (Cause.isDieReason(reason) &&
576 reason.defect instanceof OwnedBySomeoneElse),
577 ),
578 ),
579 ),
580 );
581 expect(refused).toBe(true);
582 expect((yield* getLiveServer(accountId, serverId))?.name).toEqual(
583 external.name,
584 );
585 const adopted = yield* stack.deploy(resource().pipe(adopt(true)));
586 expect(adopted.serverId).toEqual(external.id);
587 expect(adopted.createdAt).toEqual(external.createdAt);
588 expect((yield* getLiveServer(accountId, serverId))?.name).toEqual(
589 "Adopted MCP server",
590 );
591 yield* stack.destroy();
592 expect(yield* getLiveServer(accountId, serverId)).toBeUndefined();
593 }).pipe(
594 Effect.ensuring(
595 // This test creates its fixture outside the stack. Reclaim that
596 // exact fixture even if the adoption assertion fails.
597 zeroTrust
598 .deleteAccessAiControlMcpServer({ accountId, id: serverId })
599 .pipe(
600 Effect.catchTag("McpServerNotFound", () => Effect.void),
601 Effect.orDie,
602 ),
603 ),
604 );
605 }).pipe(logLevel),
606 {
607 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
608 timeout: 90_000,
609 },
610);
611
612test.provider(
613 "recovers an interrupted create through explicit adoption and replaces across accounts",
614 (stack) =>
615 Effect.gen(function* () {
616 const { accountId } = yield* yield* CloudflareEnvironment;
617 yield* stack.destroy();
618 const props = {
619 hostname: HOSTNAME,
620 authType: "unauthenticated",
621 sync: false,
622 } as const;
623 const resource = () => Cloudflare.Access.McpServer("Recovered", props);
624 const initial = yield* stack.deploy(resource());
625 const provider = yield* Provider.findProvider(
626 Cloudflare.Access.McpServer,
627 );
628 // Validate account-change planning without needing credentials for a
629 // second account or issuing any request to an invented account.
630 const diff = yield* provider.diff!({
631 id: "Recovered",
632 fqn: "Recovered",
633 instanceId: "test",
634 olds: props,
635 news: props,
636 oldBindings: [],
637 newBindings: [],
638 output: { ...initial, accountId: "different-account" },
639 });
640 expect(diff?.action).toEqual("replace");
641
642 const state = yield* yield* State;
643 const fqns = yield* state.list({ stack: stack.name, stage: stack.stage });
644 let interrupted = false;
645 for (const fqn of fqns) {
646 const row = yield* state.get({
647 stack: stack.name,
648 stage: stack.stage,
649 fqn,
650 });
651 if (
652 isResourceState(row) &&
653 row.status === "created" &&
654 row.resourceType === "Cloudflare.Access.McpServer"
655 ) {
656 yield* state.set({
657 stack: stack.name,
658 stage: stack.stage,
659 fqn,
660 value: { ...row, status: "creating", attr: undefined },
661 });
662 interrupted = true;
663 }
664 }
665 expect(interrupted).toBe(true);
666 const refused = yield* stack.deploy(resource()).pipe(
667 Effect.as(false),
668 Effect.catchCause((cause) =>
669 Effect.succeed(
670 cause.reasons.some(
671 (reason) =>
672 (Cause.isFailReason(reason) &&
673 reason.error instanceof OwnedBySomeoneElse) ||
674 (Cause.isDieReason(reason) &&
675 reason.defect instanceof OwnedBySomeoneElse),
676 ),
677 ),
678 ),
679 );
680 expect(refused).toBe(true);
681 const recovered = yield* stack.deploy(resource().pipe(adopt(true)));
682 expect(recovered.serverId).toEqual(initial.serverId);
683 expect(recovered.createdAt).toEqual(initial.createdAt);
684 yield* stack.destroy();
685 expect(yield* getLiveServer(accountId, initial.serverId)).toBeUndefined();
686 }).pipe(logLevel),
687 {
688 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
689 timeout: 90_000,
690 },
691);
692
693test.provider(
694 "recreates a server deleted outside the stack",
695 (stack) =>
696 Effect.gen(function* () {
697 const { accountId } = yield* yield* CloudflareEnvironment;
698 yield* stack.destroy();
699 const resource = (name: string) =>
700 Cloudflare.Access.McpServer("Deleted", {
701 hostname: HOSTNAME,
702 authType: "unauthenticated",
703 name,
704 sync: false,
705 });
706 const initial = yield* stack.deploy(resource("Before deletion"));
707 yield* zeroTrust.deleteAccessAiControlMcpServer({
708 accountId,
709 id: initial.serverId,
710 });
711 expect(yield* getLiveServer(accountId, initial.serverId)).toBeUndefined();
712 const recovered = yield* stack.deploy(resource("After deletion"));
713 expect(recovered.serverId).toEqual(initial.serverId);
714 expect(
715 (yield* getLiveServer(accountId, recovered.serverId))?.name,
716 ).toEqual("After deletion");
717 yield* stack.destroy();
718 expect(
719 yield* getLiveServer(accountId, recovered.serverId),
720 ).toBeUndefined();
721 }).pipe(logLevel),
722 {
723 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
724 timeout: 90_000,
725 },
726);
727
728test.provider(
729 "rotating only the bearer credential changes what the upstream receives",
730 (stack) =>
731 Effect.gen(function* () {
732 const { accountId } = yield* yield* CloudflareEnvironment;
733 yield* stack.destroy();
734 const upstream = () =>
735 Cloudflare.Worker("AuthenticatedUpstream", {
736 main: new URL("./fixtures/mcp-auth.ts", import.meta.url).pathname,
737 // Cloudflare discovery can hit Worker-to-Worker error 1042 on
738 // workers.dev. Custom domains support that fetch path.
739 domain: "mcp-credential-rotation.alchemy-test-2.us",
740 workersDev: true,
741 });
742 const worker = yield* stack.deploy(upstream());
743 // Probe the script without negative-caching the new custom domain
744 // in the local DNS resolver. Discovery checks the custom domain below.
745 const health = yield* Test.getWhenReady(
746 `${worker.urls.find((url) => url.endsWith(".workers.dev"))!}/health`,
747 );
748 expect(health.status).toEqual(200);
749 const deploy = (version: "v1" | "v2") =>
750 stack.deploy(
751 Effect.gen(function* () {
752 const endpoint = yield* upstream();
753 return yield* Cloudflare.Access.McpServer("Rotating", {
754 hostname: endpoint.url.pipe(Output.map((url) => `${url}/mcp`)),
755 authType: "bearer",
756 authCredentials: Redacted.make(`alchemy-mcp-rotation-${version}`),
757 });
758 }),
759 );
760 const initial = yield* deploy("v1");
761 // Worker health and Cloudflare's discovery run in different locations.
762 // Let the new route propagate before testing credential rotation. This
763 // setup retry must not wrap the v2 deploy or mask a rotation failure.
764 const ready =
765 initial.status === "ready"
766 ? initial
767 : yield* zeroTrust
768 .syncAccessAiControlMcpServer({ accountId, id: initial.serverId })
769 .pipe(
770 Effect.catchTag("McpServerSyncFailure", () => Effect.void),
771 Effect.andThen(
772 zeroTrust.readAccessAiControlMcpServer({
773 accountId,
774 id: initial.serverId,
775 }),
776 ),
777 Effect.repeat({
778 while: (server) => server.status !== "ready",
779 schedule: Schedule.spaced("1 second"),
780 times: 8,
781 }),
782 );
783 expect(ready.error || undefined).toBeUndefined();
784 expect(ready.status).toEqual("ready");
785 expect(ready.tools.map((tool) => tool.name)).toContain(
786 "authenticated_v1",
787 );
788 // A rejected upstream credential must retain the complete failure body.
789 yield* zeroTrust.updateAccessAiControlMcpServer({
790 accountId,
791 id: initial.serverId,
792 authCredentials: "invalid-credential",
793 });
794 const failure = yield* zeroTrust
795 .syncAccessAiControlMcpServer({
796 accountId,
797 id: initial.serverId,
798 })
799 .pipe(
800 Effect.catchTag("McpServerSyncFailure", (error) =>
801 Effect.succeed(error.body),
802 ),
803 );
804 const body = Schema.decodeUnknownSync(
805 Schema.Struct({
806 success: Schema.Literal(false),
807 result: Schema.Struct({
808 status: Schema.Literal("error"),
809 error: Schema.String,
810 error_details: Schema.Struct({ status_code: Schema.Number }),
811 }),
812 }),
813 )(failure);
814 expect(body.result.error).toContain("Unauthorized");
815 expect(body.result.error_details.status_code).toEqual(401);
816 const rotated = yield* deploy("v2");
817 expect(rotated.serverId).toEqual(initial.serverId);
818 expect(rotated.createdAt).toEqual(initial.createdAt);
819 // Discovery can report a transient routing error even after fetching
820 // the new capabilities. Verify the credential through the returned tools.
821 expect(rotated.tools.map((tool) => tool.name)).toContain(
822 "authenticated_v2",
823 );
824 expect(rotated.tools.map((tool) => tool.name)).not.toContain(
825 "authenticated_v1",
826 );
827 const live = yield* getLiveServer(accountId, rotated.serverId);
828 expect(live?.tools.map((tool) => tool.name)).toContain(
829 "authenticated_v2",
830 );
831 yield* stack.destroy();
832 expect(yield* getLiveServer(accountId, rotated.serverId)).toBeUndefined();
833 }).pipe(logLevel),
834 {
835 tags: [
836 "provider:cloudflare",
837 "provider:cloudflare:access",
838 "provider:cloudflare:worker",
839 "live",
840 ],
841 timeout: 120_000,
842 },
843);