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 | ); | ||