A selfhost scenario runs the whole thing through the product: link via a real OIDC sign-in, connect with no subjectToken in the payload, call a tool. Both emulator ledgers carry the proof -- the sign-in, executor's own code redemption, the exchange and the redemption -- and the MCP server sees no authorize request. Renewal past ID-token expiry is not asserted here: the Okta emulator's token lifetimes are compiled-in constants with no seed knob to compress them. That claim is proven hermetically in the SDK suite instead.
Unified · Split
| @@ -0,0 +1,442 @@ | ||
| 1 | +// Selfhost-only: the WORK IDENTITY half of MCP Enterprise-Managed Authorization, | |
| 2 | +// driven end to end against the same two emulators as | |
| 3 | +// `mcp-enterprise-managed-auth.test.ts`. | |
| 4 | +// | |
| 5 | +// That scenario proves the ID-JAG chain works when someone hands executor an | |
| 6 | +// identity assertion. This one proves the product can GET one — which is the | |
| 7 | +// part a browser cannot do, because the identity provider's client secret is | |
| 8 | +// server-side. The claim under test, in order: | |
| 9 | +// | |
| 10 | +// 1. the user links their enterprise identity ONCE, through a real OIDC | |
| 11 | +// authorization-code flow that executor builds and redeems itself, and | |
| 12 | +// 2. what executor takes custody of is the IdP's REFRESH token | |
| 13 | +// (draft-ietf-oauth-identity-assertion-authz-grant §4.5) — the durable | |
| 14 | +// subject, not the ~1h ID token, and | |
| 15 | +// 3. the enterprise-managed connect then carries NO subjectToken at all, and | |
| 16 | +// still connects with no consent step, and a tool call rides the result. | |
| 17 | +// | |
| 18 | +// Both emulators' request ledgers are the proof: executor's own responses only | |
| 19 | +// show what executor believes, while the ledgers show the upstream calls it | |
| 20 | +// actually made — the sign-in, the exchange, the redemption, and the tool call. | |
| 21 | +// | |
| 22 | +// NOT covered here, deliberately: renewal after the ID token would have expired. | |
| 23 | +// The Okta emulator's token lifetimes are compiled-in constants (access/ID token | |
| 24 | +// 3600s, ID-JAG 300s) with no seed knob to compress them, so a real | |
| 25 | +// past-expiry renewal is not expressible against it inside a test's budget. That | |
| 26 | +// claim is proven hermetically instead, in the SDK's `oauth-work-identity.test.ts` | |
| 27 | +// ("renews after the ID token that started the link would have died"), by | |
| 28 | +// revoking everything the sign-in issued that expires and showing renewal | |
| 29 | +// continues — which is precisely the state the hour brings. | |
| 30 | +import { randomBytes } from "node:crypto"; | |
| 31 | +import { createServer } from "node:net"; | |
| 32 | + | |
| 33 | +import { assert, expect } from "@effect/vitest"; | |
| 34 | +import { Effect } from "effect"; | |
| 35 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 36 | +import { createEmulator, type Emulator, type LedgerEntry } from "@executor-js/emulate"; | |
| 37 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 38 | +import { | |
| 39 | + AuthTemplateSlug, | |
| 40 | + ConnectionName, | |
| 41 | + IntegrationSlug, | |
| 42 | + OAuthClientSlug, | |
| 43 | +} from "@executor-js/sdk/shared"; | |
| 44 | + | |
| 45 | +import { scenario } from "../src/scenario"; | |
| 46 | +import { Api, Target } from "../src/services"; | |
| 47 | + | |
| 48 | +const api = composePluginApi([mcpHttpPlugin()] as const); | |
| 49 | + | |
| 50 | +const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; | |
| 51 | +const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; | |
| 52 | +const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token"; | |
| 53 | + | |
| 54 | +// The Okta emulator's default seed: one user on the `default` authorization | |
| 55 | +// server. The OAuth client is minted per run so nothing depends on the sample | |
| 56 | +// client's id. | |
| 57 | +const OKTA_USER = "testuser@okta.local"; | |
| 58 | + | |
| 59 | +// What the MCP emulator advertises in both its RFC 9728 and RFC 8414 metadata. | |
| 60 | +const SERVER_SCOPES = ["repo", "read:user"] as const; | |
| 61 | + | |
| 62 | +const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; | |
| 63 | + | |
| 64 | +const availablePort = Effect.callback<number>((resume) => { | |
| 65 | + const probe = createServer(); | |
| 66 | + probe.listen(0, "127.0.0.1", () => { | |
| 67 | + const address = probe.address(); | |
| 68 | + const port = typeof address === "object" && address ? address.port : 0; | |
| 69 | + probe.close(() => { | |
| 70 | + resume(Effect.succeed(port)); | |
| 71 | + }); | |
| 72 | + }); | |
| 73 | +}); | |
| 74 | + | |
| 75 | +/** A locally spawned emulator on an OS-assigned port, closed with the scope — | |
| 76 | + * local rather than hosted so the behavior asserted is the version this | |
| 77 | + * checkout pins, not whatever was last deployed. */ | |
| 78 | +const emulator = (service: "okta" | "mcp") => | |
| 79 | + Effect.acquireRelease( | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const port = yield* availablePort; | |
| 82 | + return yield* Effect.promise(() => createEmulator({ service, port })); | |
| 83 | + }), | |
| 84 | + (instance: Emulator) => Effect.promise(() => instance.close()).pipe(Effect.ignore), | |
| 85 | + ); | |
| 86 | + | |
| 87 | +const requireString = (value: string | undefined | null, what: string): string => { | |
| 88 | + if (!value) throw new Error(`emulator returned no ${what}`); | |
| 89 | + return value; | |
| 90 | +}; | |
| 91 | + | |
| 92 | +/** Sign in at the identity provider against the authorization URL EXECUTOR | |
| 93 | + * built, and hand back the code its callback would have received. | |
| 94 | + * | |
| 95 | + * This is the one step a headless scenario has to stand in for, and it stands | |
| 96 | + * in for the browser only: every parameter posted below is read straight off | |
| 97 | + * executor's own authorize URL, so the client id, redirect URI, scopes and PKCE | |
| 98 | + * challenge under test are the ones the product chose, not ones invented here. */ | |
| 99 | +const signInAt = (authorizationUrl: string) => | |
| 100 | + Effect.promise(async (): Promise<string> => { | |
| 101 | + const authorize = new URL(authorizationUrl); | |
| 102 | + const callbackUrl = new URL(authorize); | |
| 103 | + callbackUrl.pathname = `${authorize.pathname}/callback`; | |
| 104 | + callbackUrl.search = ""; | |
| 105 | + | |
| 106 | + const body = new URLSearchParams({ user_ref: OKTA_USER }); | |
| 107 | + for (const key of [ | |
| 108 | + "redirect_uri", | |
| 109 | + "scope", | |
| 110 | + "state", | |
| 111 | + "nonce", | |
| 112 | + "client_id", | |
| 113 | + "code_challenge", | |
| 114 | + "code_challenge_method", | |
| 115 | + ]) { | |
| 116 | + const value = authorize.searchParams.get(key); | |
| 117 | + if (value !== null) body.set(key, value); | |
| 118 | + } | |
| 119 | + body.set("response_mode", "query"); | |
| 120 | + | |
| 121 | + const response = await fetch(callbackUrl.toString(), { | |
| 122 | + method: "POST", | |
| 123 | + headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 124 | + redirect: "manual", | |
| 125 | + body, | |
| 126 | + }); | |
| 127 | + if (response.status !== 302) { | |
| 128 | + throw new Error( | |
| 129 | + `IdP authorize answered ${response.status}, expected a 302: ${await response.text()}`, | |
| 130 | + ); | |
| 131 | + } | |
| 132 | + const location = requireString(response.headers.get("location"), "authorize redirect"); | |
| 133 | + return requireString(new URL(location).searchParams.get("code"), "authorization code"); | |
| 134 | + }); | |
| 135 | + | |
| 136 | +const ledger = (instance: Emulator) => Effect.promise(() => instance.ledger.list()); | |
| 137 | + | |
| 138 | +const entryFor = (entries: readonly LedgerEntry[], operationId: string): LedgerEntry | undefined => | |
| 139 | + entries.find((entry) => entry.operationId === operationId); | |
| 140 | + | |
| 141 | +const callGetMeCode = (slug: string, connection: string) => ` | |
| 142 | +const result = await tools.${slug}.org.${connection}.get_me({}); | |
| 143 | +return { ok: result.ok, payload: result.ok ? result.data : result.error }; | |
| 144 | +`; | |
| 145 | + | |
| 146 | +type SandboxToolOutcome = { | |
| 147 | + readonly ok: boolean; | |
| 148 | + readonly payload?: { readonly login?: string }; | |
| 149 | +}; | |
| 150 | + | |
| 151 | +scenario( | |
| 152 | + "MCP enterprise-managed authorization · linking a work identity once lets a connect carry no assertion at all", | |
| 153 | + { timeout: 180_000 }, | |
| 154 | + Effect.scoped( | |
| 155 | + Effect.gen(function* () { | |
| 156 | + const target = yield* Target; | |
| 157 | + const { client: makeApiClient } = yield* Api; | |
| 158 | + const identity = yield* target.newIdentity(); | |
| 159 | + const client = yield* makeApiClient(api, identity); | |
| 160 | + | |
| 161 | + const okta = yield* emulator("okta"); | |
| 162 | + const mcp = yield* emulator("mcp"); | |
| 163 | + const mcpEndpoint = `${mcp.url}/mcp`; | |
| 164 | + | |
| 165 | + // The link redirects the user's browser back to EXECUTOR's own OAuth | |
| 166 | + // callback — the same one every interactive connect uses. Registering it | |
| 167 | + // with the identity provider is what an enterprise administrator does | |
| 168 | + // once; the emulator matches redirect URIs exactly, so this scenario fails | |
| 169 | + // loudly if executor ever asks for a different one. | |
| 170 | + const executorCallback = new URL("/api/oauth/callback", target.baseUrl).toString(); | |
| 171 | + | |
| 172 | + // One client identity across BOTH registrations — the same client the user | |
| 173 | + // signs in to, presenting itself to the Resource Authorization Server | |
| 174 | + // (draft §5 client continuity). | |
| 175 | + const credential = yield* Effect.promise(() => | |
| 176 | + okta.credentials.mint({ | |
| 177 | + type: "oauth-authorization-code", | |
| 178 | + name: "Executor E2E work identity client", | |
| 179 | + redirect_uris: [executorCallback], | |
| 180 | + }), | |
| 181 | + ); | |
| 182 | + const clientId = requireString(credential.client_id, "IdP client_id"); | |
| 183 | + const clientSecret = requireString(credential.client_secret, "IdP client_secret"); | |
| 184 | + const idpTokenUrl = requireString(credential.token_url, "IdP token endpoint"); | |
| 185 | + const idpAuthorizationUrl = requireString(credential.authorization_url, "IdP authorize URL"); | |
| 186 | + | |
| 187 | + const integration = IntegrationSlug.make(freshSlug("mcp_wid")); | |
| 188 | + const idpClient = OAuthClientSlug.make(freshSlug("wid_idp")); | |
| 189 | + const serverClient = OAuthClientSlug.make(freshSlug("wid_server")); | |
| 190 | + const template = AuthTemplateSlug.make("oauth2"); | |
| 191 | + const workIdentityRef = { | |
| 192 | + owner: "org", | |
| 193 | + idpClient, | |
| 194 | + idpClientOwner: "org", | |
| 195 | + } as const; | |
| 196 | + | |
| 197 | + yield* client.mcp.addServer({ | |
| 198 | + payload: { | |
| 199 | + transport: "remote", | |
| 200 | + name: "Enterprise-managed MCP (emulate)", | |
| 201 | + endpoint: mcpEndpoint, | |
| 202 | + slug: String(integration), | |
| 203 | + authenticationTemplate: [ | |
| 204 | + { | |
| 205 | + kind: "oauth2", | |
| 206 | + enterpriseIdentityProvider: { client: idpClient, clientOwner: "org" }, | |
| 207 | + }, | |
| 208 | + ], | |
| 209 | + }, | |
| 210 | + }); | |
| 211 | + | |
| 212 | + yield* Effect.ensuring( | |
| 213 | + Effect.gen(function* () { | |
| 214 | + // The client's registration AT THE IdP. Unlike the assertion-carrying | |
| 215 | + // scenario, this one is RUN as a flow: the link is its authorization- | |
| 216 | + // code flow, redeemed server-side with this secret. | |
| 217 | + yield* client.oauth.createClient({ | |
| 218 | + payload: { | |
| 219 | + owner: "org", | |
| 220 | + slug: idpClient, | |
| 221 | + authorizationUrl: idpAuthorizationUrl, | |
| 222 | + tokenUrl: idpTokenUrl, | |
| 223 | + grant: "authorization_code", | |
| 224 | + clientId, | |
| 225 | + clientSecret, | |
| 226 | + }, | |
| 227 | + }); | |
| 228 | + // The client's registration AT THE RESOURCE AUTHORIZATION SERVER. | |
| 229 | + yield* client.oauth.createClient({ | |
| 230 | + payload: { | |
| 231 | + owner: "org", | |
| 232 | + slug: serverClient, | |
| 233 | + authorizationUrl: `${mcp.url}/authorize`, | |
| 234 | + tokenUrl: `${mcp.url}/token`, | |
| 235 | + grant: "id_jag", | |
| 236 | + clientId, | |
| 237 | + clientSecret, | |
| 238 | + resource: mcpEndpoint, | |
| 239 | + }, | |
| 240 | + }); | |
| 241 | + | |
| 242 | + // --------------------------------------------------------------- | |
| 243 | + // Phase 1 — nothing is linked, so an assertion-less connect asks for | |
| 244 | + // a LINK. This is the state every user starts in, and the field below | |
| 245 | + // is what a console branches on to know what to offer. | |
| 246 | + // --------------------------------------------------------------- | |
| 247 | + expect( | |
| 248 | + (yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status, | |
| 249 | + "no work identity is held before the user links one", | |
| 250 | + ).toBe("unlinked"); | |
| 251 | + | |
| 252 | + const unlinked = yield* client.oauth | |
| 253 | + .start({ | |
| 254 | + payload: { | |
| 255 | + owner: "org", | |
| 256 | + client: serverClient, | |
| 257 | + clientOwner: "org", | |
| 258 | + name: ConnectionName.make("premature"), | |
| 259 | + integration, | |
| 260 | + template, | |
| 261 | + enterprise: { idpClient, idpClientOwner: "org" }, | |
| 262 | + }, | |
| 263 | + }) | |
| 264 | + .pipe(Effect.flip); | |
| 265 | + assert( | |
| 266 | + unlinked._tag === "OAuthStartError", | |
| 267 | + "an unlinked user is a start failure, not a transport or decoding fault", | |
| 268 | + ); | |
| 269 | + expect( | |
| 270 | + unlinked.workIdentityLinkRequired, | |
| 271 | + "the remedy travels as a FIELD — a console cannot decide from a sentence whether to open the link flow, retry, or offer per-server consent", | |
| 272 | + ).toBe(true); | |
| 273 | + | |
| 274 | + // --------------------------------------------------------------- | |
| 275 | + // Phase 2 — the link. A real OIDC authorization-code flow, built by | |
| 276 | + // executor and redeemed by executor with the IdP app's secret. | |
| 277 | + // --------------------------------------------------------------- | |
| 278 | + const startedLink = yield* client.oauth.startWorkIdentityLink({ | |
| 279 | + payload: workIdentityRef, | |
| 280 | + }); | |
| 281 | + const authorize = new URL(startedLink.authorizationUrl); | |
| 282 | + expect( | |
| 283 | + authorize.searchParams.get("redirect_uri"), | |
| 284 | + "the link comes back to executor's own OAuth callback, so an enterprise registers one redirect URI and not two", | |
| 285 | + ).toBe(executorCallback); | |
| 286 | + expect( | |
| 287 | + authorize.searchParams.get("scope")?.split(" ").sort(), | |
| 288 | + "`openid` is where the account claims come from and `offline_access` is where the durable refresh token comes from", | |
| 289 | + ).toEqual(["offline_access", "openid"]); | |
| 290 | + expect(authorize.searchParams.get("client_id")).toBe(clientId); | |
| 291 | + | |
| 292 | + const code = yield* signInAt(startedLink.authorizationUrl); | |
| 293 | + const linked = yield* client.oauth.completeWorkIdentityLink({ | |
| 294 | + payload: { state: startedLink.state, code }, | |
| 295 | + }); | |
| 296 | + | |
| 297 | + assert(linked.status === "linked", "the completion reports the account that was linked"); | |
| 298 | + expect( | |
| 299 | + linked.subjectTokenType, | |
| 300 | + "§4.5: custody is the IdP's REFRESH token. An ID token here would strand every managed connection about an hour after it was made", | |
| 301 | + ).toBe(REFRESH_TOKEN_TYPE); | |
| 302 | + expect( | |
| 303 | + linked.expiresAt, | |
| 304 | + "and a refresh token carries no client-visible deadline, which is the property being bought", | |
| 305 | + ).toBeNull(); | |
| 306 | + expect(linked.label, "the console can say WHO is linked").toContain("testuser"); | |
| 307 | + | |
| 308 | + expect( | |
| 309 | + (yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status, | |
| 310 | + "and the status a console polls agrees with the completion", | |
| 311 | + ).toBe("linked"); | |
| 312 | + | |
| 313 | + // --------------------------------------------------------------- | |
| 314 | + // Phase 3 — the headline: connect with NO assertion on the request. | |
| 315 | + // --------------------------------------------------------------- | |
| 316 | + const connected = yield* client.oauth.start({ | |
| 317 | + payload: { | |
| 318 | + owner: "org", | |
| 319 | + client: serverClient, | |
| 320 | + clientOwner: "org", | |
| 321 | + name: ConnectionName.make("main"), | |
| 322 | + integration, | |
| 323 | + template, | |
| 324 | + // No `subjectToken`. Nothing in this payload could authorize | |
| 325 | + // anything; the held identity is resolved server-side. | |
| 326 | + enterprise: { idpClient, idpClientOwner: "org" }, | |
| 327 | + }, | |
| 328 | + }); | |
| 329 | + | |
| 330 | + assert( | |
| 331 | + connected.status === "connected", | |
| 332 | + "a linked user connects an enterprise-managed server with neither an assertion in hand nor a consent screen", | |
| 333 | + ); | |
| 334 | + expect( | |
| 335 | + connected.connection.oauthScope?.split(" ").sort(), | |
| 336 | + "the connection carries the scopes the IdP granted", | |
| 337 | + ).toEqual([...SERVER_SCOPES].sort()); | |
| 338 | + | |
| 339 | + const executed = yield* client.executions.execute({ | |
| 340 | + payload: { code: callGetMeCode(String(integration), "main"), autoApprove: true }, | |
| 341 | + }); | |
| 342 | + expect(executed.status, "the tool call completed").toBe("completed"); | |
| 343 | + const outcome = JSON.parse(executed.text) as SandboxToolOutcome; | |
| 344 | + expect(outcome.ok, executed.text).toBe(true); | |
| 345 | + | |
| 346 | + // --- Ledger: the identity provider saw a real sign-in. ----------- | |
| 347 | + const oktaEntries = yield* ledger(okta); | |
| 348 | + expect( | |
| 349 | + oktaEntries.some((entry) => entry.path.endsWith("/v1/authorize/callback")), | |
| 350 | + "the link ran an actual OIDC sign-in at the identity provider", | |
| 351 | + ).toBe(true); | |
| 352 | + const codeExchange = oktaEntries.find( | |
| 353 | + (entry) => | |
| 354 | + entry.path.endsWith("/v1/token") && | |
| 355 | + (entry.request.body as { readonly grant_type?: string } | undefined)?.grant_type === | |
| 356 | + "authorization_code", | |
| 357 | + ); | |
| 358 | + expect( | |
| 359 | + codeExchange?.response.status, | |
| 360 | + "and executor — not the browser — redeemed the code, which is the only place the IdP app's secret may be used", | |
| 361 | + ).toBe(200); | |
| 362 | + expect( | |
| 363 | + (codeExchange?.request.body as { readonly client_id?: string } | undefined)?.client_id, | |
| 364 | + "as the registered enterprise client", | |
| 365 | + ).toBe(clientId); | |
| 366 | + | |
| 367 | + // --- Ledger: the connect exchanged that custody for an ID-JAG. ---- | |
| 368 | + const exchange = entryFor(oktaEntries, "okta.oauth.tokenExchange"); | |
| 369 | + expect(exchange, "executor ran an RFC 8693 exchange at the IdP").toBeTruthy(); | |
| 370 | + expect(exchange?.response.status).toBe(200); | |
| 371 | + // `subject_token_type` is absent from the ledger: it redacts every | |
| 372 | + // `*token*` field before recording. That the exchange presented the | |
| 373 | + // REFRESH-token subject is asserted on the wire in the hermetic | |
| 374 | + // protocol tests, and on the product surface by `subjectTokenType` | |
| 375 | + // above — the ledger's job here is the routing fields. | |
| 376 | + expect( | |
| 377 | + exchange?.request.body, | |
| 378 | + "the exchange names the MCP server's authorization server and resource", | |
| 379 | + ).toMatchObject({ | |
| 380 | + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, | |
| 381 | + audience: mcp.url, | |
| 382 | + resource: mcpEndpoint, | |
| 383 | + client_id: clientId, | |
| 384 | + scope: SERVER_SCOPES.join(" "), | |
| 385 | + }); | |
| 386 | + | |
| 387 | + // --- Ledger: what executor did with the ID-JAG it got back. ------ | |
| 388 | + const mcpEntries = yield* ledger(mcp); | |
| 389 | + const redemption = entryFor(mcpEntries, "mcp.oauth.jwtBearer"); | |
| 390 | + expect( | |
| 391 | + redemption, | |
| 392 | + "executor redeemed the ID-JAG at the resource authorization server", | |
| 393 | + ).toBeTruthy(); | |
| 394 | + expect(redemption?.response.status).toBe(200); | |
| 395 | + expect(redemption?.request.body).toMatchObject({ | |
| 396 | + grant_type: JWT_BEARER_GRANT_TYPE, | |
| 397 | + client_id: clientId, | |
| 398 | + }); | |
| 399 | + expect( | |
| 400 | + mcpEntries.filter((entry) => entry.path === "/authorize"), | |
| 401 | + "no interactive authorization request was ever made at the MCP server", | |
| 402 | + ).toEqual([]); | |
| 403 | + | |
| 404 | + const toolCall = mcpEntries.find( | |
| 405 | + (entry) => entry.path === "/mcp" && entry.method === "POST", | |
| 406 | + ); | |
| 407 | + expect( | |
| 408 | + toolCall?.identity.user, | |
| 409 | + "the MCP server saw the enterprise user with the granted scopes", | |
| 410 | + ).toMatchObject({ scopes: [...SERVER_SCOPES] }); | |
| 411 | + | |
| 412 | + // --------------------------------------------------------------- | |
| 413 | + // Phase 4 — unlinking is a fact about the IDENTITY. The connection is | |
| 414 | + // untouched; a re-link is what revives it. | |
| 415 | + // --------------------------------------------------------------- | |
| 416 | + yield* client.oauth.unlinkWorkIdentity({ payload: workIdentityRef }); | |
| 417 | + expect((yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status).toBe( | |
| 418 | + "unlinked", | |
| 419 | + ); | |
| 420 | + const connections = yield* client.connections.list({ query: { integration } }); | |
| 421 | + expect( | |
| 422 | + connections.map((connection) => String(connection.name)).sort(), | |
| 423 | + "forgetting the identity removes no connection — the premature attempt minted none, and the real one survives", | |
| 424 | + ).toEqual(["main"]); | |
| 425 | + }), | |
| 426 | + Effect.gen(function* () { | |
| 427 | + yield* client.oauth.unlinkWorkIdentity({ payload: workIdentityRef }).pipe(Effect.ignore); | |
| 428 | + yield* client.connections | |
| 429 | + .remove({ params: { owner: "org", integration, name: ConnectionName.make("main") } }) | |
| 430 | + .pipe(Effect.ignore); | |
| 431 | + yield* client.oauth | |
| 432 | + .removeClient({ params: { slug: serverClient }, payload: { owner: "org" } }) | |
| 433 | + .pipe(Effect.ignore); | |
| 434 | + yield* client.oauth | |
| 435 | + .removeClient({ params: { slug: idpClient }, payload: { owner: "org" } }) | |
| 436 | + .pipe(Effect.ignore); | |
| 437 | + yield* client.mcp.removeServer({ params: { slug: integration } }).pipe(Effect.ignore); | |
| 438 | + }), | |
| 439 | + ); | |
| 440 | + }), | |
| 441 | + ), | |
| 442 | +); | |