diff --git a/apps/cloud/src/routes/app/org.tsx b/apps/cloud/src/routes/app/org.tsx
index a9ded0b7c8..9047cd09a3 100644
--- a/apps/cloud/src/routes/app/org.tsx
+++ b/apps/cloud/src/routes/app/org.tsx
@@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@executor-js/react/components/dropdown-menu";
import { orgMembersAtom } from "@executor-js/react/api/account-atoms";
+import { EnterpriseIdentityProviderSection } from "@executor-js/react/components/enterprise-idp-section";
import { OrgPage as SharedOrgPage } from "@executor-js/react/pages/org";
import { orgDomainsAtom, getDomainVerificationLink, deleteDomain } from "../../web/org-atoms";
import { deleteOrganization, useAuth } from "../../web/auth";
@@ -61,6 +62,7 @@ function OrgPage() {
{/* Shared members / roles / invite / org-name surface. */}
}
+ enterpriseIdpSection={}
upgradeAction={
@@ -72,13 +74,38 @@ function OrgPage() {
);
}
+/** Whether the caller administers this organization, derived from the members
+ * list already loaded for this page. False while it loads, so an admin-only
+ * control never flashes for someone who cannot use it.
+ *
+ * CLIENT-SIDE UX GATING ONLY: every endpoint behind these controls enforces
+ * its own authorization. Hiding a control the server would refuse is a
+ * courtesy, not the boundary. */
+function useIsOrgAdmin(): boolean {
+ const membersResult = useAtomValue(orgMembersAtom);
+ return AsyncResult.match(membersResult, {
+ onInitial: () => false,
+ onFailure: () => false,
+ onSuccess: ({ value }) =>
+ value.members.some((m) => m.isCurrentUser && m.status === "active" && m.role === "admin"),
+ });
+}
+
+// The organization's enterprise identity provider (MCP Enterprise-Managed
+// Authorization). Admin-only, on the same gate as the danger zone: registering
+// it decides how every member of the workspace authorizes to enterprise-managed
+// MCP servers, which is an administrator's call, not a member's.
+function EnterpriseIdpSection() {
+ return useIsOrgAdmin() ? : null;
+}
+
// Destructive org teardown, admin-only. Hidden entirely for non-admins (the
// backend enforces admin + name-confirmation regardless). Deleting the org
// removes the workspace and all of its data for every member, cancels billing,
// and logs the caller out.
function DangerZoneSection() {
const auth = useAuth();
- const membersResult = useAtomValue(orgMembersAtom);
+ const isAdmin = useIsOrgAdmin();
const doDelete = useAtomSet(deleteOrganization, { mode: "promiseExit" });
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
@@ -86,16 +113,6 @@ function DangerZoneSection() {
const organizationName = auth.status === "authenticated" ? auth.organization?.name : undefined;
- // Only admins may delete. Derive the caller's role from the members list
- // (already loaded for this page); render nothing while it loads or for
- // members, so a delete control never flashes for someone who can't use it.
- const isAdmin = AsyncResult.match(membersResult, {
- onInitial: () => false,
- onFailure: () => false,
- onSuccess: ({ value }) =>
- value.members.some((m) => m.isCurrentUser && m.status === "active" && m.role === "admin"),
- });
-
if (!isAdmin || !organizationName) return null;
const confirmed = confirmText.trim() === organizationName.trim();
diff --git a/e2e/selfhost/mcp-enterprise-managed-console.test.ts b/e2e/selfhost/mcp-enterprise-managed-console.test.ts
new file mode 100644
index 0000000000..620032e42b
--- /dev/null
+++ b/e2e/selfhost/mcp-enterprise-managed-console.test.ts
@@ -0,0 +1,508 @@
+// Selfhost (browser, recorded): the CONSOLE half of MCP Enterprise-Managed
+// Authorization. The protocol half — the ID-JAG chain itself and the
+// administrator denial — is `mcp-enterprise-managed-auth.test.ts`; this
+// scenario is about what an administrator and a member can see and do.
+//
+// Three claims, in the order a workspace meets them:
+//
+// 1. An administrator marks a server as managed FROM THE UI, and that
+// declaration survives `configureMcpAuth`. It is written through the same
+// replace-mode save that rewrites the whole auth-method list, so a
+// declaration the credential editor cannot express is one unrelated edit
+// away from being erased — the assertion below is that it is not.
+// 2. A member's managed connection is visibly managed and offers NO local
+// revocation. Remove would claim a revocation that did not happen (the
+// identity provider still authorizes them, and the next call hands access
+// straight back), and Reconnect re-runs a consent step this profile does
+// not have. Both belong at the identity provider.
+// 3. An administrator's denial stops the connect and does NOT fall back to
+// the interactive per-server flow. The MCP emulator's ledger is the proof:
+// it shows the requests executor did NOT make.
+//
+// Two emulators stand in for the pilot's real parties: `okta` is the customer's
+// identity provider (it runs the real OIDC sign-on, mints ID-JAGs over RFC 8693
+// and enforces an administrator policy table), `mcp` is the third-party server
+// and its Resource Authorization Server.
+//
+// GAP, deliberately not papered over: minting the connection below goes through
+// the typed API, not the console, because `oauth.start`'s `enterprise` input
+// requires the caller to HOLD the identity assertion and no browser surface can
+// obtain one. See the branch's report; the console work here is everything
+// around that one leg.
+import { randomBytes } from "node:crypto";
+import { createServer } from "node:net";
+
+import { assert, expect } from "@effect/vitest";
+import { Effect, Predicate } from "effect";
+import type { Page } from "playwright";
+import { composePluginApi } from "@executor-js/api/server";
+import { createEmulator, type Emulator } from "@executor-js/emulate";
+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
+import {
+ AuthTemplateSlug,
+ ConnectionName,
+ IntegrationSlug,
+ OAuthClientSlug,
+} from "@executor-js/sdk/shared";
+
+import { scenario } from "../src/scenario";
+import { Api, Browser, Target } from "../src/services";
+import { visit } from "../src/surfaces/browser";
+
+const api = composePluginApi([mcpHttpPlugin()] as const);
+
+const OKTA_USER = "testuser@okta.local";
+const OKTA_AUTH_SERVER = "default";
+const SSO_REDIRECT_URI = "http://localhost:3000/callback";
+const ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token";
+
+// The reserved (owner, slug) the organization's identity provider is registered
+// under — the SAME identity the org settings section writes, so a server marked
+// managed in the console names exactly this app. Self-host has no organization
+// admin page, so the registration is seeded through the typed API here; the
+// browser drives everything downstream of it.
+const IDP_CLIENT = OAuthClientSlug.make("enterprise-identity-provider");
+
+const MANAGED_BADGE = "Managed by your organization";
+
+const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
+
+const availablePort = Effect.callback((resume) => {
+ const probe = createServer();
+ probe.listen(0, "127.0.0.1", () => {
+ const address = probe.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ probe.close(() => {
+ resume(Effect.succeed(port));
+ });
+ });
+});
+
+/** A locally spawned emulator on an OS-assigned port, closed with the scope.
+ * Local rather than hosted: this scenario depends on behavior that shipped in
+ * `@executor-js/emulate` 0.14.0 (Okta minting ID-JAGs), and the npm package is
+ * the version this checkout pins. */
+const emulator = (service: "okta" | "mcp") =>
+ Effect.acquireRelease(
+ Effect.gen(function* () {
+ const port = yield* availablePort;
+ return yield* Effect.promise(() => createEmulator({ service, port }));
+ }),
+ (instance: Emulator) => Effect.promise(() => instance.close()).pipe(Effect.ignore),
+ );
+
+const requireString = (value: string | undefined | null, what: string): string => {
+ if (!value) throw new Error(`emulator returned no ${what}`);
+ return value;
+};
+
+/** Single sign-on at the identity provider, ending with the ID token the host
+ * holds on the member's behalf. THE one step this console cannot yet perform
+ * for itself — see the header. */
+const singleSignOn = (input: {
+ readonly issuerBaseUrl: string;
+ readonly clientId: string;
+ readonly clientSecret: string;
+}) =>
+ Effect.promise(async (): Promise => {
+ const authorize = await fetch(
+ `${input.issuerBaseUrl}/oauth2/${OKTA_AUTH_SERVER}/v1/authorize/callback`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ redirect: "manual",
+ body: new URLSearchParams({
+ user_ref: OKTA_USER,
+ redirect_uri: SSO_REDIRECT_URI,
+ scope: "openid profile email",
+ client_id: input.clientId,
+ response_mode: "query",
+ auth_server_id: OKTA_AUTH_SERVER,
+ }),
+ },
+ );
+ if (authorize.status !== 302) {
+ throw new Error(`IdP authorize answered ${authorize.status}, expected a 302`);
+ }
+ const location = requireString(authorize.headers.get("location"), "authorize redirect");
+ const code = requireString(new URL(location).searchParams.get("code"), "authorization code");
+
+ const token = await fetch(`${input.issuerBaseUrl}/oauth2/${OKTA_AUTH_SERVER}/v1/token`, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: SSO_REDIRECT_URI,
+ client_id: input.clientId,
+ client_secret: input.clientSecret,
+ }),
+ });
+ if (!token.ok) throw new Error(`IdP token endpoint answered ${token.status}`);
+ const body = (await token.json()) as { readonly id_token?: string };
+ return requireString(body.id_token, "id_token");
+ });
+
+const connectionsSection = (page: Page) =>
+ page.locator("section").filter({
+ has: page.getByRole("heading", { level: 3, name: "Connections" }),
+ });
+
+const callGetMeCode = (slug: string, connection: string) => `
+const result = await tools.${slug}.org.${connection}.get_me({});
+return { ok: result.ok, payload: result.ok ? result.data : result.error };
+`;
+
+scenario(
+ "MCP enterprise-managed authorization (console) · an administrator marks a server managed, and the managed connection offers no local revocation",
+ { timeout: 240_000 },
+ Effect.scoped(
+ Effect.gen(function* () {
+ const target = yield* Target;
+ const browser = yield* Browser;
+ const { client: makeApiClient } = yield* Api;
+ const identity = yield* target.newIdentity();
+ const client = yield* makeApiClient(api, identity);
+
+ const okta = yield* emulator("okta");
+ const mcp = yield* emulator("mcp");
+ const mcpEndpoint = `${mcp.url}/mcp`;
+
+ // One client identity across both registrations (draft §5 client
+ // continuity): the app the member signs in to at the identity provider is
+ // the app that presents itself to the Resource Authorization Server.
+ const credential = yield* Effect.promise(() =>
+ okta.credentials.mint({
+ type: "oauth-authorization-code",
+ name: "Executor E2E enterprise client",
+ redirect_uris: [SSO_REDIRECT_URI],
+ }),
+ );
+ const clientId = requireString(credential.client_id, "IdP client_id");
+ const clientSecret = requireString(credential.client_secret, "IdP client_secret");
+ const idpTokenUrl = requireString(credential.token_url, "IdP token endpoint");
+ const idpAuthorizationUrl = requireString(credential.authorization_url, "IdP authorize URL");
+
+ const subjectToken = yield* singleSignOn({
+ issuerBaseUrl: okta.url,
+ clientId,
+ clientSecret,
+ });
+
+ const integration = IntegrationSlug.make(freshSlug("mcp_ema_ui"));
+ const serverClient = OAuthClientSlug.make(freshSlug("ema_server"));
+ const template = AuthTemplateSlug.make("oauth2");
+ const managedConnection = ConnectionName.make("main");
+
+ // The server starts life ORDINARY — a plain oauth2 MCP server with no
+ // enterprise declaration. Marking it managed is the browser's job below,
+ // which is the whole point: a declaration that only ever arrives through
+ // `addServer` would never exercise the save path that can erase it.
+ yield* client.mcp.addServer({
+ payload: {
+ transport: "remote",
+ name: "Enterprise-managed MCP (emulate)",
+ endpoint: mcpEndpoint,
+ slug: String(integration),
+ authenticationTemplate: [{ kind: "oauth2" }],
+ },
+ });
+
+ yield* Effect.ensuring(
+ Effect.gen(function* () {
+ // The organization's identity provider, under the reserved identity
+ // the org settings section owns. Both endpoints are recorded exactly
+ // as that section records them.
+ yield* client.oauth.createClient({
+ payload: {
+ owner: "org",
+ slug: IDP_CLIENT,
+ authorizationUrl: idpAuthorizationUrl,
+ tokenUrl: idpTokenUrl,
+ grant: "authorization_code",
+ clientId,
+ clientSecret,
+ },
+ });
+ // The server-side registration an administrator makes through the
+ // OAuth app form's "Enterprise identity assertion" grant: `id_jag`
+ // plus the RFC 9728 resource discovery starts from.
+ yield* client.oauth.createClient({
+ payload: {
+ owner: "org",
+ slug: serverClient,
+ authorizationUrl: `${mcp.url}/authorize`,
+ tokenUrl: `${mcp.url}/token`,
+ grant: "id_jag",
+ clientId,
+ clientSecret,
+ resource: mcpEndpoint,
+ },
+ });
+
+ // -----------------------------------------------------------------
+ // 1. The administrator marks the server managed, in the console.
+ // -----------------------------------------------------------------
+ yield* browser.session(identity, async ({ page, step }) => {
+ await step("An administrator opens the MCP server they want to manage", async () => {
+ await visit(page, `/integrations/${String(integration)}`);
+ await page.getByRole("button", { name: "Edit" }).first().waitFor({ timeout: 30_000 });
+ });
+
+ await step("Turn on 'Managed by your organization'", async () => {
+ await page.getByRole("button", { name: "Edit" }).first().click();
+ const toggle = page.locator("#ema-managed-server");
+ await toggle.waitFor({ timeout: 30_000 });
+ expect(
+ await toggle.getAttribute("data-state"),
+ "an ordinary server starts unmanaged",
+ ).toBe("unchecked");
+ await toggle.click();
+ // Waited for on the DOM rather than read once: the switch
+ // animates, and this step's screenshot should show the state the
+ // administrator sees, not a frame mid-transition.
+ await page
+ .locator('#ema-managed-server[data-state="checked"]')
+ .waitFor({ timeout: 10_000 });
+ await page
+ .locator('#ema-managed-server [data-slot="switch-thumb"][data-state="checked"]')
+ .waitFor({ timeout: 10_000 });
+ });
+
+ await step("Save — the declaration is written onto the server", async () => {
+ await page.getByRole("button", { name: "Save" }).click();
+ await page
+ .getByText("Authentication methods updated.", { exact: true })
+ .waitFor({ timeout: 30_000 });
+ });
+
+ await step("Reopening the server shows it is still managed", async () => {
+ // Not a repeat of the save assertion: this reads the toggle back
+ // from the SERVER's stored declaration on a fresh mount, which is
+ // the state a second administrator would arrive at.
+ await page.getByRole("button", { name: "Edit" }).first().click();
+ const reopened = page.locator('#ema-managed-server[data-state="checked"]');
+ await reopened.waitFor({ timeout: 30_000 });
+ // Left open, and scrolled to: this step's screenshot is the
+ // artifact showing the stored declaration as an administrator
+ // finds it.
+ await reopened.scrollIntoViewIfNeeded();
+ });
+ });
+
+ // The declaration reached storage AND survived the replace-mode save
+ // that rewrote the whole method list. Read back through the catalog,
+ // which is where every connect path learns of it.
+ const catalog = yield* client.integrations.get({ params: { slug: integration } });
+ const declared = catalog.authMethods.find((method) => method.kind === "oauth");
+ expect(
+ declared?.oauth?.enterpriseIdentityProvider,
+ "the console's toggle declared the organization's identity provider on this server",
+ ).toEqual({ client: String(IDP_CLIENT), clientOwner: "org" });
+ expect(
+ declared?.oauth?.supportsDynamicRegistration,
+ "declaring an identity provider leaves the interactive route advertised",
+ ).toBe(true);
+ const enterprise = declared?.oauth?.enterpriseIdentityProvider;
+ assert(enterprise, "the connect below drives off the projected pointer");
+
+ // -----------------------------------------------------------------
+ // 2. A member connects with their work identity, and uses the server.
+ // This leg goes through the typed API — see the file header.
+ // -----------------------------------------------------------------
+ const connected = yield* client.oauth.start({
+ payload: {
+ owner: "org",
+ client: serverClient,
+ clientOwner: "org",
+ name: managedConnection,
+ integration,
+ template,
+ enterprise: {
+ idpClient: enterprise.client,
+ idpClientOwner: enterprise.clientOwner,
+ subjectToken,
+ subjectTokenType: ID_TOKEN_TYPE,
+ },
+ },
+ });
+ assert(
+ connected.status === "connected",
+ "the enterprise grant connects with no authorize redirect",
+ );
+ expect(
+ connected.connection.enterpriseManaged,
+ "the connection reports itself managed, which is what the console branches on",
+ ).toBe(true);
+
+ const executed = yield* client.executions.execute({
+ payload: {
+ code: callGetMeCode(String(integration), String(managedConnection)),
+ autoApprove: true,
+ },
+ });
+ expect(executed.status, "the tool call completed").toBe("completed");
+ expect((JSON.parse(executed.text) as { readonly ok: boolean }).ok, executed.text).toBe(
+ true,
+ );
+
+ // -----------------------------------------------------------------
+ // 3. The member's view of the managed connection.
+ // -----------------------------------------------------------------
+ yield* browser.session(identity, async ({ page, step }) => {
+ const connections = connectionsSection(page);
+ const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first();
+
+ await step("A member opens the managed server's connections", async () => {
+ await visit(page, `/integrations/${String(integration)}`);
+ await connections
+ .getByText(String(managedConnection), { exact: true })
+ .waitFor({ timeout: 30_000 });
+ });
+
+ await step("The connection is visibly managed by the organization", async () => {
+ await connections.getByText(MANAGED_BADGE, { exact: true }).waitFor({
+ timeout: 30_000,
+ });
+ // The badge is a claim; this is the consequence of it, said in
+ // words the member can act on.
+ await connections
+ .getByText(/Revoke access at your identity provider, not here\./)
+ .waitFor({ timeout: 30_000 });
+ });
+
+ await step("Its menu offers no Remove and no Reconnect", async () => {
+ await menuTrigger.click();
+ // Present: the actions that are still the member's to take.
+ await page.getByRole("menuitem", { name: "Check now" }).waitFor({ timeout: 30_000 });
+ await page.getByRole("menuitem", { name: "Edit" }).waitFor({ timeout: 30_000 });
+ // Absent: WITHHELD, not disabled. A local Remove would claim a
+ // revocation that did not happen, and Reconnect re-runs a consent
+ // step this profile does not have.
+ expect(
+ await page.getByRole("menuitem", { name: "Remove" }).count(),
+ "an enterprise-managed connection cannot be removed locally",
+ ).toBe(0);
+ expect(
+ await page.getByRole("menuitem", { name: "Reconnect" }).count(),
+ "an enterprise-managed connection has no interactive flow to re-run",
+ ).toBe(0);
+ // Left open on purpose: this step's screenshot is the artifact
+ // that shows the menu as the member sees it.
+ });
+ });
+
+ // -----------------------------------------------------------------
+ // 4. The administrator denies this client at the identity provider.
+ // Clear both ledgers first so every entry below belongs to the
+ // blocked attempt.
+ // -----------------------------------------------------------------
+ yield* Effect.promise(() => okta.ledger.clear());
+ yield* Effect.promise(() => mcp.ledger.clear());
+ yield* Effect.promise(() =>
+ okta.seed({
+ token_exchange_policies: [
+ { name: "Block the executor client", client_id: clientId, effect: "DENY" },
+ ],
+ }),
+ );
+
+ const blocked = yield* client.oauth
+ .start({
+ payload: {
+ owner: "org",
+ client: serverClient,
+ clientOwner: "org",
+ name: ConnectionName.make("blocked"),
+ integration,
+ template,
+ enterprise: {
+ idpClient: enterprise.client,
+ idpClientOwner: enterprise.clientOwner,
+ subjectToken,
+ subjectTokenType: ID_TOKEN_TYPE,
+ },
+ },
+ })
+ .pipe(Effect.flip);
+
+ assert(
+ Predicate.isTagged(blocked, "OAuthStartError"),
+ "a policy denial is a start failure, not a transport or decoding fault",
+ );
+ // The two fields the console branches on. It must never decide this
+ // from the wording of a message: getting it wrong means offering the
+ // interactive flow, which walks the member around the control the
+ // identity provider just exercised.
+ expect(blocked.blockedByAdmin, "the denial reaches the console as a FIELD").toBe(true);
+ expect(
+ blocked.oauthErrorCode,
+ "the provider's own code travels structurally, so support can trace it",
+ ).toBe("invalid_target");
+
+ // THE anti-fallback claim, proven by absence: had executor quietly
+ // offered the ordinary per-server flow, the MCP server would have seen
+ // an authorize request, a registration, or another redemption.
+ const afterDenial = yield* Effect.promise(() => mcp.ledger.list());
+ expect(
+ afterDenial.filter(
+ (entry) =>
+ entry.path === "/authorize" ||
+ entry.path === "/register" ||
+ entry.operationId === "mcp.oauth.jwtBearer",
+ ),
+ "a policy denial does not fall back to interactive OAuth",
+ ).toEqual([]);
+
+ const connections = yield* client.connections.list({ query: { integration } });
+ expect(
+ connections.map((connection) => String(connection.name)).sort(),
+ "the blocked attempt minted no connection",
+ ).toEqual([String(managedConnection)]);
+
+ // -----------------------------------------------------------------
+ // 5. The denial changes nothing the console offers: the managed row
+ // still has no route around the identity provider's decision.
+ // -----------------------------------------------------------------
+ yield* browser.session(identity, async ({ page, step }) => {
+ const rows = connectionsSection(page);
+ await step("After the denial, the console still offers no way around it", async () => {
+ await visit(page, `/integrations/${String(integration)}`);
+ await rows
+ .getByText(String(managedConnection), { exact: true })
+ .waitFor({ timeout: 30_000 });
+ await rows.getByText(MANAGED_BADGE, { exact: true }).waitFor({ timeout: 30_000 });
+ await rows.locator('button[aria-haspopup="menu"]').first().click();
+ // Wait for a menu item that IS there before counting the ones
+ // that are not: an unopened menu would make every absence
+ // assertion below pass for the wrong reason.
+ await page.getByRole("menuitem", { name: "Check now" }).waitFor({ timeout: 30_000 });
+ expect(
+ await page.getByRole("menuitem", { name: "Reconnect" }).count(),
+ "the interactive route is exactly what the identity provider closed",
+ ).toBe(0);
+ expect(
+ await page.getByRole("menuitem", { name: "Remove" }).count(),
+ "and a denial does not turn revocation into a local action either",
+ ).toBe(0);
+ });
+ });
+ }),
+ Effect.gen(function* () {
+ yield* client.connections
+ .remove({
+ params: { owner: "org", integration, name: managedConnection },
+ })
+ .pipe(Effect.ignore);
+ yield* client.oauth
+ .removeClient({ params: { slug: serverClient }, payload: { owner: "org" } })
+ .pipe(Effect.ignore);
+ yield* client.oauth
+ .removeClient({ params: { slug: IDP_CLIENT }, payload: { owner: "org" } })
+ .pipe(Effect.ignore);
+ yield* client.mcp.removeServer({ params: { slug: integration } }).pipe(Effect.ignore);
+ }),
+ );
+ }),
+ ),
+);
diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts
index c93e983cb3..6c40512bdc 100644
--- a/packages/core/api/src/connections/api.ts
+++ b/packages/core/api/src/connections/api.ts
@@ -62,6 +62,11 @@ const ConnectionResponse = Schema.Struct({
// Last persisted health-check verdict (written by every checkHealth run),
// so the list can show alive/expired at a glance without probing.
lastHealth: Schema.NullOr(HealthCheckResult),
+ // True when the connection was minted through MCP Enterprise-Managed
+ // Authorization: it mirrors organization policy and holds no durable local
+ // grant, so a console must not offer to delete it. Read-only — nothing on
+ // this surface can set it.
+ enterpriseManaged: Schema.Boolean,
});
const ToolResponse = Schema.Struct({
diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts
index 9ae476a97f..de766b07e9 100644
--- a/packages/core/api/src/handlers/connections.ts
+++ b/packages/core/api/src/handlers/connections.ts
@@ -30,6 +30,7 @@ const toResponse = (c: Connection) => ({
oauthScope: c.oauthScope ?? null,
missingOAuthScopes: c.missingOAuthScopes ?? [],
lastHealth: c.lastHealth ?? null,
+ enterpriseManaged: c.enterpriseManaged ?? false,
});
const toolToResponse = (t: Tool) => ({
diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts
index 92c3e5ed72..898fafd07b 100644
--- a/packages/core/api/src/handlers/oauth.ts
+++ b/packages/core/api/src/handlers/oauth.ts
@@ -46,6 +46,7 @@ const connectionToResponse = (c: Connection) => ({
oauthClientOwner: c.oauthClientOwner ?? null,
oauthScope: c.oauthScope ?? null,
missingOAuthScopes: c.missingOAuthScopes ?? [],
+ enterpriseManaged: c.enterpriseManaged ?? false,
});
const startResultToResponse = (result: ConnectResult) =>
diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts
index 96e76a26c1..a68d66d3d4 100644
--- a/packages/core/api/src/oauth/api.ts
+++ b/packages/core/api/src/oauth/api.ts
@@ -52,6 +52,12 @@ const ConnectionResponse = Schema.Struct({
oauthClientOwner: Schema.NullOr(Owner),
oauthScope: Schema.NullOr(Schema.String),
missingOAuthScopes: Schema.Array(Schema.String),
+ // True when this connect took the enterprise-managed branch (the ID-JAG
+ // chain) rather than the interactive one. A `start` against an `id_jag`
+ // client can still land here as `false` — the profile falls back when the
+ // server does not advertise it — so the caller reads the outcome, not its
+ // own intent.
+ enterpriseManaged: Schema.Boolean,
});
// ---------------------------------------------------------------------------
diff --git a/packages/core/sdk/src/connection.ts b/packages/core/sdk/src/connection.ts
index 9009011774..d84ab17645 100644
--- a/packages/core/sdk/src/connection.ts
+++ b/packages/core/sdk/src/connection.ts
@@ -61,6 +61,21 @@ export interface Connection {
* "has this expired?" at a glance in the connections list without probing.
* Null/absent = never checked. */
readonly lastHealth?: HealthCheckResult | null;
+ /** True when this connection was minted through MCP Enterprise-Managed
+ * Authorization and renews itself from the enterprise identity assertion
+ * persisted alongside it (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`).
+ *
+ * A BOOLEAN, deliberately: the wiring behind it (which IdP registration,
+ * which audience) is a server concern, and a console needs exactly one fact
+ * from it — that this connection mirrors organization policy rather than a
+ * grant the user holds. That single fact changes what the UI may offer:
+ * there is no durable local grant to delete, so a local "remove" would be a
+ * lie, and revocation belongs at the identity provider.
+ *
+ * It is NOT derivable client-side from `oauthClient`: a client whose grant
+ * is `id_jag` still falls back to the interactive flow against a server that
+ * does not advertise the profile, and such a connection is ordinary. */
+ readonly enterpriseManaged?: boolean;
}
/** Identify one connection — unique by (owner, integration, name). */
diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts
index f1b9443477..a4a4717bd3 100644
--- a/packages/core/sdk/src/executor.ts
+++ b/packages/core/sdk/src/executor.ts
@@ -818,6 +818,22 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[]
: [];
};
+/** Project a credential-resolution failure's ADMINISTRATOR verdict onto the
+ * health result, so the console reads "your organization declined this" as
+ * structure rather than parsing the sentence in `detail`. Empty for every
+ * ordinary failure — an expired grant, a dead refresh token — which keeps the
+ * blocked branch impossible to reach by accident. */
+export const healthAdministratorVerdict = (failure: {
+ readonly blockedByAdmin?: boolean;
+ readonly oauthErrorCode?: string;
+}): { readonly blockedByAdmin?: true; readonly oauthErrorCode?: string } =>
+ failure.blockedByAdmin === true
+ ? {
+ blockedByAdmin: true,
+ ...(failure.oauthErrorCode === undefined ? {} : { oauthErrorCode: failure.oauthErrorCode }),
+ }
+ : {};
+
/** The definitive refresh rejection recorded on `provider_state`, or null.
* Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying
* cannot change the verdict); cleared by the reconnect mint, which rewrites
@@ -852,6 +868,10 @@ const rowToConnection = (row: ConnectionRow): Connection => {
oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state),
lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),
+ // Read from the SAME persisted state the renewal path follows, so the
+ // console and the credential lifecycle can never disagree about which
+ // connections are enterprise-managed.
+ enterpriseManaged: enterpriseManagedStateFrom(decodeJsonColumn(row.provider_state)) !== null,
};
};
@@ -3455,6 +3475,7 @@ export const createExecutor = (null);
+ // Whether this server is declared enterprise-managed, and by which
+ // registration. Read off the stored oauth2 method rather than kept as a
+ // parallel flag: the declaration IS the state, and the toggle below only
+ // decides whether the organization's registration is attached to it.
+ const declaredProvider = useMemo(() => {
+ for (const method of server.config.authenticationTemplate) {
+ if (method.kind === "oauth2" && method.enterpriseIdentityProvider !== undefined) {
+ return method.enterpriseIdentityProvider;
+ }
+ }
+ return undefined;
+ }, [server.config.authenticationTemplate]);
+
+ const organizationProvider = useEnterpriseIdentityProviderDescriptor() ?? undefined;
+ const [managed, setManaged] = useState(declaredProvider !== undefined);
+ // The declaration this save would write: the organization's registration
+ // while the toggle is on, and — when the organization has since removed its
+ // registration — whatever the server already names, so an unrelated edit
+ // cannot quietly un-manage a live server.
+ const nextProvider: McpEnterpriseIdentityProvider | undefined = managed
+ ? (organizationProvider ?? declaredProvider)
+ : undefined;
+
// The edited methods, slugs preserved for seeded rows so existing
// connections (bound by template slug) stay attached. New rows omit the
- // slug — the backend assigns kind-based ones.
+ // slug — the backend assigns kind-based ones. Each row is reconciled against
+ // the method it was seeded from, because `configureMcpAuth` runs in `replace`
+ // mode here: anything the credential editor cannot express — the
+ // enterprise-managed declaration above all — has to be carried forward
+ // deliberately or it is erased by an unrelated edit.
const editedMethods = useMemo(
() =>
list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => {
- const input = mcpAuthMethodInputFromEditorValue(row.value);
- return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input;
+ const edited = mcpAuthMethodInputFromEditorValue(row.value);
+ const declared = edited.kind === "oauth2" ? mcpOAuthMethodInput(nextProvider) : edited;
+ return row.seedSlug !== undefined ? { ...declared, slug: row.seedSlug } : declared;
}),
- [list.rows],
+ [list.rows, nextProvider],
);
const methodsChanged = useMemo(() => {
@@ -118,10 +152,25 @@ function RemoteEdit(props: {
if (method.kind === "apikey" && current.kind === "apikey") {
return !samePlacements(method.placements, current.placements);
}
+ if (method.kind === "oauth2" && current.kind === "oauth2") {
+ return !sameEnterpriseIdentityProvider(
+ method.enterpriseIdentityProvider,
+ current.enterpriseIdentityProvider,
+ );
+ }
return false;
});
}, [editedMethods, server.config.authenticationTemplate]);
+ const hasOAuthMethod = server.config.authenticationTemplate.some(
+ (method: McpAuthMethod) => method.kind === "oauth2",
+ );
+ // Offered only where it can mean something: this server authenticates with
+ // OAuth, and either the organization has registered an identity provider or
+ // this server is already managed by one (so it can still be turned off).
+ const canDeclareManaged =
+ hasOAuthMethod && (organizationProvider !== undefined || declaredProvider !== undefined);
+
// Staged apply, run by the sheet's Save when the method list changed.
const applyStaged = useCallback(async (): Promise => {
setError(null);
@@ -168,6 +217,38 @@ function RemoteEdit(props: {
footerHint="Connections pick one of these methods. Removing a method detaches connections created against it."
/>
+ {/* Enterprise-Managed Authorization, per server. The organization's
+ identity provider is registered once (Organization settings); this is
+ where an administrator says WHICH servers authorize through it.
+ Declaring it can never take an ordinary server off the interactive
+ flow — the connect path still requires the server to advertise the
+ grant profile — so the copy promises a route, not an outcome. */}
+ {canDeclareManaged ? (
+
+
+
+
+
+ Members connect with their work identity instead of consenting to this server. Your
+ identity provider decides who gets access, and revokes it.
+
+ {organizationProvider === undefined ? (
+
+ Your organization no longer has an identity provider registered. This server keeps
+ the one it already names until you turn this off.
+
+ ) : null}
+
+
+ ) : null}
+
{error && }
);
diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx
index 13c2252afc..ba94d670a7 100644
--- a/packages/plugins/mcp/src/react/McpSignInButton.tsx
+++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx
@@ -2,18 +2,14 @@ import { useMemo, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
-import {
- AuthTemplateSlug,
- IntegrationSlug,
- type Connection,
- type Owner,
-} from "@executor-js/sdk/shared";
+import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared";
import { connectionsAllAtom } from "@executor-js/react/api/atoms";
import { AddAccountModal } from "@executor-js/react/components/add-account-modal";
import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in";
import type { AuthMethod } from "@executor-js/react/lib/auth-placements";
import { mcpServerAtom } from "./atoms";
+import { authMethodsFromConfig } from "./auth-method-config";
import type { McpAuthMethod } from "../sdk/types";
// ---------------------------------------------------------------------------
@@ -47,21 +43,15 @@ export default function McpSignInButton(props: { integrationId: string; owner?:
(connection: Connection) => connection.integration === slug,
);
+ // Projected through the plugin's ONE codec rather than assembled here: the
+ // server's enterprise-managed declaration rides on this method, and a
+ // hand-built copy silently dropped it — leaving this button offering
+ // per-server consent for a server the organization manages.
const methods = useMemo(
() =>
remote === null || oauthMethod === null
? []
- : [
- {
- id: oauthMethod.slug,
- label: "OAuth",
- kind: "oauth",
- source: "spec",
- template: AuthTemplateSlug.make(oauthMethod.slug),
- placements: [],
- oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true },
- },
- ],
+ : authMethodsFromConfig([oauthMethod], remote.endpoint),
[remote, oauthMethod],
);
const initialState = useMemo(
diff --git a/packages/plugins/mcp/src/react/auth-method-config.test.ts b/packages/plugins/mcp/src/react/auth-method-config.test.ts
index b6a8556e5f..39b4d78e2e 100644
--- a/packages/plugins/mcp/src/react/auth-method-config.test.ts
+++ b/packages/plugins/mcp/src/react/auth-method-config.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
+import { OAuthClientSlug } from "@executor-js/sdk/shared";
import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
import {
@@ -6,6 +7,8 @@ import {
editorValueFromMcpAuthMethod,
mcpAuthMethodInputFromEditorValue,
mcpAuthMethodInputsFromPlacements,
+ mcpOAuthMethodInput,
+ sameEnterpriseIdentityProvider,
} from "./auth-method-config";
describe("mcpAuthMethodInputFromEditorValue", () => {
@@ -175,6 +178,112 @@ describe("authMethodsFromConfig", () => {
});
});
+// ---------------------------------------------------------------------------
+// Enterprise-Managed Authorization: the per-server declaration.
+//
+// The declaration is a POINTER at the organization's identity-provider
+// registration, and it is what puts a server on the work-identity route
+// instead of per-server consent. Two things must hold or the feature silently
+// stops working: it has to reach the console (a dropped pointer means the
+// connect path offers ordinary consent for a managed server), and it must not
+// be invented by any surface that cannot see the organization's registration.
+// ---------------------------------------------------------------------------
+
+const PROVIDER = {
+ client: OAuthClientSlug.make("enterprise-identity-provider"),
+ clientOwner: "org",
+} as const;
+
+describe("authMethodsFromConfig · enterprise-managed declaration", () => {
+ it("carries the server's identity-provider pointer onto the rendered method", () => {
+ const methods = authMethodsFromConfig(
+ [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }],
+ "https://mcp.example.com/mcp",
+ );
+ expect(methods[0]?.oauth?.enterpriseIdentityProvider).toEqual(PROVIDER);
+ });
+
+ it("leaves the interactive route advertised beside it", () => {
+ // Declaring a provider asks the connect path to TRY the enterprise branch.
+ // Whether it is taken still depends on the server advertising the grant
+ // profile, so the ordinary route must remain available.
+ const methods = authMethodsFromConfig(
+ [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }],
+ "https://mcp.example.com/mcp",
+ );
+ expect(methods[0]?.oauth?.supportsDynamicRegistration).toBe(true);
+ expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");
+ });
+
+ it("declares nothing for an ordinary oauth2 server", () => {
+ const methods = authMethodsFromConfig(
+ [{ slug: "oauth2", kind: "oauth2" }],
+ "https://mcp.example.com/mcp",
+ );
+ expect(methods[0]?.oauth?.enterpriseIdentityProvider).toBeUndefined();
+ });
+});
+
+describe("mcpOAuthMethodInput", () => {
+ it("attaches the organization's registration when the server is managed", () => {
+ expect(mcpOAuthMethodInput(PROVIDER)).toEqual({
+ kind: "oauth2",
+ enterpriseIdentityProvider: PROVIDER,
+ });
+ });
+
+ it("omits the key entirely when it is not — never an explicit undefined", () => {
+ // `configureMcpAuth` decodes this against a union; an explicit
+ // `enterpriseIdentityProvider: undefined` is a different wire value and is
+ // rejected, so absence has to be real absence.
+ const input = mcpOAuthMethodInput(undefined);
+ expect(input).toEqual({ kind: "oauth2" });
+ expect(Object.hasOwn(input, "enterpriseIdentityProvider")).toBe(false);
+ });
+});
+
+describe("mcpAuthMethodInputFromEditorValue · enterprise-managed declaration", () => {
+ it("invents no declaration from the credential editor", () => {
+ // The editor edits credentials. Server policy is not one, and a surface
+ // that cannot see the organization's registration must not guess at it —
+ // the save path re-attaches it deliberately instead.
+ expect(
+ mcpAuthMethodInputFromEditorValue({
+ kind: "oauth",
+ authorizationUrl: "",
+ tokenUrl: "",
+ scopes: [],
+ }),
+ ).toEqual({ kind: "oauth2" });
+ });
+});
+
+describe("sameEnterpriseIdentityProvider", () => {
+ it("compares the pointer by value, not by reference", () => {
+ // The stored copy is decoded from JSON, so it is never the same object as
+ // the one the console just built; a reference check would report every
+ // save as a change.
+ expect(sameEnterpriseIdentityProvider({ ...PROVIDER }, { ...PROVIDER })).toBe(true);
+ });
+
+ it("distinguishes a different registration", () => {
+ expect(sameEnterpriseIdentityProvider(PROVIDER, { ...PROVIDER, clientOwner: "user" })).toBe(
+ false,
+ );
+ expect(
+ sameEnterpriseIdentityProvider(PROVIDER, {
+ ...PROVIDER,
+ client: OAuthClientSlug.make("other"),
+ }),
+ ).toBe(false);
+ });
+
+ it("treats declaring and not declaring as different", () => {
+ expect(sameEnterpriseIdentityProvider(PROVIDER, undefined)).toBe(false);
+ expect(sameEnterpriseIdentityProvider(undefined, undefined)).toBe(true);
+ });
+});
+
describe("mcpAuthMethodInputsFromPlacements", () => {
it("builds ONE method carrying every named placement", () => {
expect(
diff --git a/packages/plugins/mcp/src/react/auth-method-config.ts b/packages/plugins/mcp/src/react/auth-method-config.ts
index ae09ba49f6..8bb5a6aea9 100644
--- a/packages/plugins/mcp/src/react/auth-method-config.ts
+++ b/packages/plugins/mcp/src/react/auth-method-config.ts
@@ -21,6 +21,8 @@ import type {
McpAuthMethod,
McpAuthMethodInput,
McpCanonicalAuthMethodInput,
+ McpEnterpriseIdentityProvider,
+ McpOAuthMethod,
McpStdioEnvMethod,
} from "../sdk/types";
@@ -48,20 +50,37 @@ export const mcpWireAuthInput = (
method: McpAuthMethod | McpCanonicalAuthMethodInput,
): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput;
-const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({
- id: slug,
+const oauthAuthMethod = (method: McpOAuthMethod, endpoint: string): AuthMethod => ({
+ id: method.slug,
label: "OAuth",
kind: "oauth",
- source: slug.startsWith("custom_") ? "custom" : "spec",
- template: AuthTemplateSlug.make(slug),
+ source: method.slug.startsWith("custom_") ? "custom" : "spec",
+ template: AuthTemplateSlug.make(method.slug),
placements: [],
- oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true },
+ oauth: {
+ discoveryUrl: endpoint,
+ supportsDynamicRegistration: true,
+ // The server's own enterprise-managed declaration, carried onto the
+ // presentational method. `supportsDynamicRegistration` stays true beside
+ // it on purpose: declaring an identity provider is an ADDITIONAL route, not
+ // a replacement, and a server that turns out not to advertise the ID-JAG
+ // profile must still be connectable the ordinary way.
+ ...(method.enterpriseIdentityProvider === undefined
+ ? {}
+ : { enterpriseIdentityProvider: method.enterpriseIdentityProvider }),
+ },
});
/** Convert a generic editor value into one MCP auth-method input (no slug —
* the backend assigns carrier-derived slugs). An apikey value keeps every
* named placement (headers and query params mix freely); one with no usable
- * placement falls back to `none`. */
+ * placement falls back to `none`.
+ *
+ * Deliberately carries NO enterprise-managed declaration: that is server
+ * policy, not a credential, so it has no editor field to come from. The
+ * surface that saves a managed server (`EditMcpIntegration`) re-attaches it
+ * explicitly from its own toggle, which is what keeps `configureMcpAuth`'s
+ * `replace` mode from erasing it on an unrelated edit. */
export function mcpAuthMethodInputFromEditorValue(
value: AuthTemplateEditorValue,
): McpCanonicalAuthMethodInput {
@@ -71,6 +90,31 @@ export function mcpAuthMethodInputFromEditorValue(
}) as McpCanonicalAuthMethodInput;
}
+/** One oauth2 method input carrying the organization's declaration, or none.
+ *
+ * The single place an enterprise-managed pointer is attached to a method on
+ * the way out. Written as a constructor rather than a spread at each call site
+ * so the absent case stays genuinely ABSENT: an explicit
+ * `enterpriseIdentityProvider: undefined` is a different value on the wire,
+ * and the union that decodes it rejects it. */
+export const mcpOAuthMethodInput = (
+ provider: McpEnterpriseIdentityProvider | undefined,
+): McpCanonicalAuthMethodInput =>
+ provider === undefined
+ ? { kind: "oauth2" }
+ : { kind: "oauth2", enterpriseIdentityProvider: provider };
+
+/** Whether two enterprise-managed declarations name the same registration.
+ * Compared field-by-field because the pointer travels through JSON and a
+ * decoded copy is never reference-equal to the stored one. */
+export const sameEnterpriseIdentityProvider = (
+ a: McpEnterpriseIdentityProvider | undefined,
+ b: McpEnterpriseIdentityProvider | undefined,
+): boolean =>
+ a === undefined || b === undefined
+ ? a === b
+ : String(a.client) === String(b.client) && a.clientOwner === b.clientOwner;
+
/** Convert one stored MCP method into the generic editor value. */
export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue {
if (method.kind === "oauth2") {
@@ -89,7 +133,7 @@ export function authMethodsFromConfig(
endpoint: string,
): AuthMethod[] {
return methods.map((method: McpAuthMethod): AuthMethod => {
- if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint);
+ if (method.kind === "oauth2") return oauthAuthMethod(method, endpoint);
if (method.kind === "stdio_env") return stdioEnvAuthMethod(method);
return authMethodFromSharedTemplate(method);
});
diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx
index c20018bd9d..ba496f5322 100644
--- a/packages/react/src/api/atoms.tsx
+++ b/packages/react/src/api/atoms.tsx
@@ -400,6 +400,9 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) =>
oauthScope: null,
missingOAuthScopes: [],
lastHealth: null,
+ // A pasted credential is never enterprise-managed: that state is
+ // only ever written by the ID-JAG connect path.
+ enterpriseManaged: false,
};
return [optimistic, ...rows];
}),
diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx
index f0ec361dab..ff576cc687 100644
--- a/packages/react/src/components/accounts-section.tsx
+++ b/packages/react/src/components/accounts-section.tsx
@@ -15,6 +15,12 @@ import {
} from "../api/atoms";
import { connectionWriteKeys } from "../api/reactivity-keys";
import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";
+import {
+ MANAGED_CONNECTION_BADGE,
+ MANAGED_CONNECTION_BLOCKED_LABEL,
+ MANAGED_CONNECTION_REVOCATION_HINT,
+ connectionRowPolicy,
+} from "../lib/managed-connection";
import { useConnectionHealth } from "../lib/use-connection-health";
import { messageFromExit } from "../api/error-reporting";
import { ownerLabel, useOwnerDisplay } from "../api/owner-display";
@@ -114,8 +120,19 @@ function AccountRow(props: {
: null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null);
const displayLabel = identity ?? String(connection.name);
+ // What this row may OFFER, decided from the connection's persisted
+ // enterprise-managed state and the structured administrator verdict on its
+ // freshest health result — never from a status word or a message.
+ const policy = connectionRowPolicy(connection, probe);
+
const expired = status === "expired";
+ // An administrator decision outranks the health word. "Expired" would invite
+ // a reconnect that cannot succeed, because the route it re-runs is exactly
+ // the one the identity provider just closed.
const needsHealthAttention = status === "expired" || status === "degraded";
+ const statusLabel = policy.blockedByAdmin
+ ? MANAGED_CONNECTION_BLOCKED_LABEL
+ : HEALTH_STATUS_LABEL[status];
const healthDetail = needsHealthAttention ? probe?.detail : undefined;
const missingOAuthScopes = connection.missingOAuthScopes ?? [];
@@ -152,9 +169,26 @@ function AccountRow(props: {
className={`size-2 shrink-0 rounded-full ${indicator.dot}`}
/>
{displayLabel}
+ {policy.managed ? (
+ // Grayscale, a word, no hue — this is a fact about the connection,
+ // not a fault (design.md, "Status and semantics"). It reads
+ // distinctly from the personal rows beside it, which is the point:
+ // an additional personal account on the same integration stays
+ // possible and must stay visibly different.
+
+ {MANAGED_CONNECTION_BADGE}
+
+ ) : null}
{needsHealthAttention ? (
-
- {HEALTH_STATUS_LABEL[status]}
+
+ {statusLabel}
) : null}
{needsReconsent ? (
@@ -183,6 +217,16 @@ function AccountRow(props: {
Missing scopes: {missingOAuthScopes.join(", ")}
) : null}
+ {policy.managed ? (
+
+ {MANAGED_CONNECTION_REVOCATION_HINT}
+
+ ) : null}
+ {policy.oauthErrorCode === null ? null : (
+
+ Reference: {policy.oauthErrorCode}
+
+ )}
{props.showOwnerLabel ? (
@@ -213,12 +257,22 @@ function AccountRow(props: {
Edit
-
- Reconnect
-
-
- Remove
-
+ {/* Reconnect and Remove are WITHHELD, not disabled, for an
+ enterprise-managed connection. Reconnect re-runs the interactive
+ consent this profile has no step for, and Remove would claim the
+ member revoked access they cannot revoke here — the identity
+ provider still authorizes them, and the next call would hand it
+ straight back. Both live at the provider. */}
+ {policy.canReconnect ? (
+
+ Reconnect
+
+ ) : null}
+ {policy.canRemove ? (
+
+ Remove
+
+ ) : null}
diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx
index c2a7a5ac98..9d5fa0da09 100644
--- a/packages/react/src/components/add-account-modal.tsx
+++ b/packages/react/src/components/add-account-modal.tsx
@@ -44,6 +44,7 @@ import {
oauthClientWriteKeys,
} from "../api/reactivity-keys";
import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display";
+import { AdminBlockNotice } from "./admin-block-notice";
import { FreeformCombobox, type FreeformComboboxOption } from "./combobox";
import { messageFromExit } from "../api/error-reporting";
import { trackEvent } from "../api/analytics";
@@ -1618,6 +1619,10 @@ function AddAccountModalView(props: AddAccountModalProps) {
const isBuiltInGoogleClient =
chosenClient?.origin.kind === "first_party" &&
String(chosenClient.slug) === "first-party:google";
+ // The server names an enterprise identity provider (MCP Enterprise-Managed
+ // Authorization). Read STRUCTURALLY off the declared method — never from the
+ // grant of a picked app, which an ordinary connection can also carry.
+ const managedByOrganization = isOAuth && method?.oauth?.enterpriseIdentityProvider !== undefined;
const oauthBusy = ccBusy || oauthPopup.busy;
const cimdConnecting = cimdBusy || oauthPopup.busy;
const dcrConnecting = dcrBusy || oauthPopup.busy;
@@ -2952,11 +2957,33 @@ function AddAccountModalView(props: AddAccountModalProps) {
{continueError}
) : null}
+ {/* The server is declared enterprise-managed. Copy only, and
+ deliberately: whether this connect actually takes the
+ enterprise branch is decided at discovery — the server has to
+ advertise the ID-JAG grant profile — so the notice describes the
+ arrangement rather than promising an outcome the console cannot
+ guarantee. It is withdrawn while an administrator denial stands,
+ where the denial is the more specific truth. */}
+ {managedByOrganization && oauthPopup.adminBlock === null ? (
+
+ Your organization manages this server. Where it supports work identities, you sign
+ in with yours and your identity provider decides the access — no consent screen, and
+ nothing to revoke here.
+
+ ) : null}
{/* Above the footer, not inside the method tab: the automatic
(CIMD/DCR) flows render no tab panel at all, and putting the
sign-in error in there left a blocked popup with nothing on
screen but the button returning to "Connect". */}
- {isOAuth && oauthPopup.error ? (
+ {/* An enterprise policy denial replaces the ordinary error line —
+ it is not a transient fault, and the footer below withdraws
+ every connect action while it stands. */}
+ {isOAuth && oauthPopup.adminBlock !== null ? (
+
+ ) : isOAuth && oauthPopup.error ? (
{oauthPopup.error}
@@ -2978,7 +3005,12 @@ function AddAccountModalView(props: AddAccountModalProps) {
- registering a BYO app: the form owns its own submit, no footer;
- picked BYO OAuth app: Connect with OAuth / Connect (client creds);
- credential/no-auth method: Add connection. */}
- {cimdActive ? (
+ {/* Blocked by administrator: offer NOTHING that reconnects.
+ Every branch below is a route to the same authorization the
+ enterprise just refused, and the interactive one would route
+ the user around the control outright. Close is the only
+ action left. */}
+ {isOAuth && oauthPopup.adminBlock !== null ? null : cimdActive ? (