1import * as registry from "@distilled.cloud/gcp/agentregistry_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 MAX_DISPLAY_NAME_LENGTH,
13 encodeOwnership,
14 hasOwnershipMarker,
15 listBindings,
16 locationParent,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 resourceName,
23 retryTransient,
24 sameJson,
25 sameText,
26 toPhysicalId,
27 updateMaskOf,
28 waitForDeleteOperation,
29 waitForOperation,
30 waitForVisible,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type AuthProviderBinding = {
35 /**
36 * Auth provider resource name
37 * `projects/{project}/locations/{location}/connectors/{auth_provider}`
38 * or `.../authProviders/{auth_provider}`.
39 */
40 authProvider: string;
41 /**
42 * OAuth2 scopes requested from the auth provider.
43 */
44 scopes?: string[];
45 /**
46 * Continue URI used to reauthenticate the user and finalize the
47 * managed OAuth flow.
48 */
49 continueUri?: string;
50};
51
52export type BindingProps = {
53 /**
54 * Binding id (the `{binding}` segment of
55 * `projects/{project}/locations/{location}/bindings/{binding}`). If
56 * omitted, a unique RFC1035 name is generated. Must be 4-63 characters
57 * matching `^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`. Immutable — changing
58 * it replaces the binding.
59 */
60 bindingId?: string;
61 /**
62 * Location of the binding (`us-central1`, `global`, …). Multi-region
63 * `us` and `eu` are not supported. Immutable — changing it replaces
64 * the binding.
65 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
66 */
67 location?: string;
68 /**
69 * URN of the source agent (`urn:agent:{publisher}:{namespace}:{name}`).
70 * Immutable — changing it replaces the binding.
71 */
72 sourceIdentifier: string;
73 /**
74 * URN of the target agent, MCP server, or endpoint
75 * (`urn:agent:…`, `urn:mcp:…`, or `urn:endpoint:…`). Immutable —
76 * changing it replaces the binding.
77 */
78 targetIdentifier: string;
79 /**
80 * Human-readable name. Max 63 characters. Defaults to the binding id.
81 */
82 displayName?: string;
83 /**
84 * Human-readable description (max 2048 characters). Bindings have no
85 * labels field, so Alchemy stamps ownership into a `[alchemy …]`
86 * prefix and strips it from attributes.
87 */
88 description?: string;
89 /**
90 * Optional auth-provider binding for delegated permissions.
91 */
92 authProviderBinding?: AuthProviderBinding;
93};
94
95export type Binding = Resource<
96 "GCP.AgentRegistry.Binding",
97 BindingProps,
98 {
99 /** Full resource name `projects/{project}/locations/{location}/bindings/{binding}`. */
100 name: string;
101 /** Binding id (last path segment). */
102 bindingId: string;
103 /** Project id. */
104 project: string;
105 /** Location id of the resource. */
106 location: string;
107 /** Source agent URN. */
108 sourceIdentifier: string | undefined;
109 /** Target agent, MCP server, or endpoint URN. */
110 targetIdentifier: string | undefined;
111 /** Display name. */
112 displayName: string | undefined;
113 /** User description with the Alchemy ownership prefix stripped. */
114 description: string | undefined;
115 /** Auth-provider binding, if set. */
116 authProviderBinding: AuthProviderBinding | undefined;
117 /** RFC3339 creation timestamp. */
118 createTime: string | undefined;
119 /** RFC3339 last-update timestamp. */
120 updateTime: string | undefined;
121 },
122 never,
123 Providers
124>;
125
126/**
127 * An Agent Registry binding connecting a source agent to a target
128 * agent, MCP server, or endpoint.
129 *
130 * Create, update, and delete are long-running operations. `bindingId`,
131 * `location`, `sourceIdentifier`, and `targetIdentifier` replace the
132 * resource. Display name, description, and auth-provider settings
133 * update in place. Bindings have no labels API, so Alchemy stamps
134 * ownership into `description` so `list` / nuke can find them.
135 *
136 * ### Creating a Binding
137 * **Example:** Generated name
138 * ```typescript
139 * const binding = yield* GCP.AgentRegistry.Binding("Orchestrator", {
140 * sourceIdentifier:
141 * "urn:agent:projects-123:projects:123:locations:us-central1:agentregistry:source",
142 * targetIdentifier:
143 * "urn:mcp:projects-123:projects:123:locations:us-central1:agentregistry:tools",
144 * });
145 * ```
146 *
147 * **Example:** Named binding with a description
148 * ```typescript
149 * const binding = yield* GCP.AgentRegistry.Binding("Orchestrator", {
150 * bindingId: "orchestrator-tools",
151 * location: "us-central1",
152 * displayName: "orchestrator tools",
153 * description: "routes the orchestrator to the tools MCP server",
154 * sourceIdentifier: source.agentId,
155 * targetIdentifier: tools.mcpServerId,
156 * });
157 * ```
158 *
159 * ### Updating a Binding
160 * Change props on the same logical id; the engine keeps the physical id.
161 *
162 * **Example:** Display name and description
163 * ```typescript
164 * const binding = yield* GCP.AgentRegistry.Binding("Orchestrator", {
165 * sourceIdentifier: source.agentId,
166 * targetIdentifier: tools.mcpServerId,
167 * displayName: "orchestrator tools v2",
168 * description: "updated routing",
169 * });
170 * ```
171 *
172 * @resource
173 * @category AgentRegistry
174 */
175export const Binding = Resource<Binding>("GCP.AgentRegistry.Binding");
176
177export class BindingNotResolved extends Data.TaggedError(
178 "GCP.AgentRegistry.BindingNotResolved",
179)<{
180 name: string;
181}> {}
182
183const COLLECTION = "bindings";
184
185const toAuth = (
186 binding: registry.AuthProviderBinding | undefined,
187): AuthProviderBinding | undefined => {
188 if (binding === undefined) return undefined;
189 const authProvider = binding.authProvider;
190 if (authProvider === undefined || authProvider.length === 0) {
191 return undefined;
192 }
193 return {
194 authProvider,
195 scopes: binding.scopes,
196 continueUri: binding.continueUri,
197 };
198};
199
200const desiredAuth = (
201 binding: AuthProviderBinding | undefined,
202): registry.AuthProviderBinding | undefined => {
203 if (binding === undefined) return undefined;
204 return {
205 authProvider: binding.authProvider,
206 scopes: binding.scopes,
207 continueUri: binding.continueUri,
208 };
209};
210
211const authKey = (
212 binding: AuthProviderBinding | registry.AuthProviderBinding | undefined,
213) =>
214 binding === undefined
215 ? undefined
216 : {
217 authProvider: binding.authProvider,
218 continueUri: binding.continueUri,
219 scopes: [...(binding.scopes ?? [])].slice().sort(),
220 };
221
222const toAttrs = (item: registry.Binding, project: string, location: string) => {
223 const name = item.name ?? "";
224 const parsed = parseResourceName(name, COLLECTION);
225 const owned = parseOwnership(item.description);
226 return {
227 name,
228 bindingId: parsed.id,
229 project: parsed.project || project,
230 location: parsed.location || location,
231 sourceIdentifier: item.source?.identifier,
232 targetIdentifier: item.target?.identifier,
233 displayName: item.displayName,
234 description: owned.description,
235 authProviderBinding: toAuth(item.authProviderBinding),
236 createTime: item.createTime,
237 updateTime: item.updateTime,
238 };
239};
240
241const getByName = (name: string) =>
242 name.length === 0
243 ? Effect.succeed(undefined)
244 : registry
245 .getProjectsLocationsBindings({ name })
246 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
247
248const displayNameOf = (news: BindingProps, bindingId: string) =>
249 (news.displayName ?? bindingId).slice(0, MAX_DISPLAY_NAME_LENGTH);
250
251export const BindingProvider = () =>
252 Provider.succeed(Binding, {
253 stables: ["name", "bindingId", "project", "location", "createTime"],
254
255 diff: Effect.fn(function* ({ news, olds, output }) {
256 if (!isResolved(news)) return undefined;
257 const env = yield* GcpEnvironment.current;
258 const previousSource = olds?.sourceIdentifier ?? output?.sourceIdentifier;
259 const previousTarget = olds?.targetIdentifier ?? output?.targetIdentifier;
260 return replaceOnIdentity({
261 previousId: olds?.bindingId ?? output?.bindingId,
262 nextId: news.bindingId ?? olds?.bindingId ?? output?.bindingId,
263 previousLocation: normalizeLocation(
264 olds?.location ?? output?.location,
265 env.region,
266 ),
267 nextLocation: normalizeLocation(
268 news.location ?? olds?.location ?? output?.location,
269 env.region,
270 ),
271 extra:
272 (previousSource !== undefined &&
273 previousSource !== news.sourceIdentifier) ||
274 (previousTarget !== undefined &&
275 previousTarget !== news.targetIdentifier),
276 });
277 }),
278
279 read: Effect.fn(function* ({ id, olds, output }) {
280 const env = yield* GcpEnvironment.current;
281 const location = normalizeLocation(
282 olds?.location ?? output?.location,
283 env.region,
284 );
285 const bindingId = yield* toPhysicalId(
286 id,
287 olds?.bindingId,
288 output?.bindingId,
289 );
290 const name =
291 output?.name ??
292 resourceName(env.project, location, COLLECTION, bindingId);
293 const existing = yield* getByName(name);
294 if (existing === undefined) return undefined;
295 const attrs = toAttrs(existing, env.project, location);
296 return (yield* ownedByAlchemy(id, existing.description))
297 ? attrs
298 : Unowned(attrs);
299 }),
300
301 list: () =>
302 Effect.gen(function* () {
303 const env = yield* GcpEnvironment.current;
304 const items = yield* listBindings(env.project, env.region);
305 return items
306 .filter((item) => hasOwnershipMarker(item.description))
307 .map((item) => toAttrs(item, env.project, env.region));
308 }),
309
310 reconcile: Effect.fn(function* ({ id, news, output }) {
311 const env = yield* GcpEnvironment.current;
312 const location = normalizeLocation(
313 news.location ?? output?.location,
314 env.region,
315 );
316 const bindingId = yield* toPhysicalId(
317 id,
318 news.bindingId,
319 output?.bindingId,
320 );
321 const name = resourceName(env.project, location, COLLECTION, bindingId);
322 const parent = locationParent(env.project, location);
323 const ownership = yield* createInternalLabels(id);
324 const description = encodeOwnership(ownership, news.description);
325 const displayName = displayNameOf(news, bindingId);
326 const source = { identifier: news.sourceIdentifier };
327 const target = { identifier: news.targetIdentifier };
328 const authProviderBinding = desiredAuth(news.authProviderBinding);
329
330 let current = yield* getByName(output?.name ?? name);
331
332 if (current === undefined) {
333 const created = yield* retryTransient(
334 registry.createProjectsLocationsBindings({
335 parent,
336 bindingId,
337 body: {
338 source,
339 target,
340 displayName,
341 description,
342 authProviderBinding,
343 },
344 }),
345 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
346 if (created !== undefined) {
347 // ALREADY_EXISTS (code 6): a concurrent create won the race.
348 yield* waitForOperation(created).pipe(
349 Effect.catchIf(
350 (error) =>
351 error._tag === "GCP.OperationFailed" && error.code === 6,
352 () => Effect.void,
353 ),
354 );
355 }
356 current = yield* waitForVisible(getByName(name));
357 }
358
359 if (current === undefined) {
360 return yield* new BindingNotResolved({ name });
361 }
362
363 const currentName = current.name ?? name;
364 const displayChanged = !sameText(current.displayName, displayName);
365 const descriptionChanged = !sameText(current.description, description);
366 const sourceChanged = !sameText(
367 current.source?.identifier,
368 news.sourceIdentifier,
369 );
370 const targetChanged = !sameText(
371 current.target?.identifier,
372 news.targetIdentifier,
373 );
374 const authChanged = !sameJson(
375 authKey(current.authProviderBinding),
376 authKey(news.authProviderBinding),
377 );
378
379 const mask = updateMaskOf(
380 displayChanged ? "display_name" : undefined,
381 descriptionChanged ? "description" : undefined,
382 sourceChanged ? "source" : undefined,
383 targetChanged ? "target" : undefined,
384 authChanged ? "auth_provider_binding" : undefined,
385 );
386
387 if (mask.length > 0) {
388 const operation = yield* retryTransient(
389 registry.patchProjectsLocationsBindings({
390 name: currentName,
391 updateMask: mask,
392 body: {
393 name: currentName,
394 displayName,
395 description,
396 source,
397 target,
398 authProviderBinding,
399 },
400 }),
401 );
402 yield* waitForOperation(operation);
403 current = (yield* waitForVisible(getByName(currentName))) ?? current;
404 }
405
406 return toAttrs(current, env.project, location);
407 }),
408
409 delete: Effect.fn(function* ({ output }) {
410 if (!output.name) return;
411 const deleted = yield* retryTransient(
412 registry.deleteProjectsLocationsBindings({ name: output.name }),
413 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
414 if (deleted !== undefined) {
415 yield* waitForDeleteOperation(deleted);
416 }
417 yield* waitUntilGone(getByName(output.name));
418 }),
419 });